@celilo/cli 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +17 -2
  3. package/package.json +3 -3
  4. package/src/cli/commands/alerts-list.ts +16 -1
  5. package/src/cli/commands/backup-list.test.ts +82 -1
  6. package/src/cli/commands/backup-list.ts +113 -4
  7. package/src/cli/commands/console.ts +122 -0
  8. package/src/cli/commands/module-list.ts +3 -41
  9. package/src/cli/commands/module-publish.ts +2 -0
  10. package/src/cli/completion.ts +5 -0
  11. package/src/cli/index.ts +25 -1
  12. package/src/console/closure.test.ts +246 -0
  13. package/src/console/closure.ts +208 -0
  14. package/src/console/control-plane-boundary.test.ts +75 -0
  15. package/src/console/projection.test.ts +231 -0
  16. package/src/console/projection.ts +327 -0
  17. package/src/db/schema.ts +19 -14
  18. package/src/hooks/broker.test.ts +4 -6
  19. package/src/hooks/executor.test.ts +85 -4
  20. package/src/hooks/executor.ts +164 -9
  21. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  22. package/src/hooks/hook-state-dir.test.ts +14 -2
  23. package/src/hooks/hook-timeout.test.ts +2 -4
  24. package/src/hooks/hook-trespass.test.ts +50 -5
  25. package/src/hooks/jail.test.ts +370 -0
  26. package/src/hooks/jail.ts +491 -0
  27. package/src/hooks/mount-set.ts +24 -0
  28. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  29. package/src/manifest/icon-schema.test.ts +48 -0
  30. package/src/manifest/schema.ts +92 -0
  31. package/src/manifest/validate.test.ts +142 -0
  32. package/src/manifest/validate.ts +101 -0
  33. package/src/module/import.test.ts +116 -0
  34. package/src/module/import.ts +73 -1
  35. package/src/module/packaging/audit.ts +103 -1
  36. package/src/module/packaging/classify-module-path.test.ts +36 -0
  37. package/src/module/packaging/package-rules.ts +18 -0
  38. package/src/policy/capability-shape-baseline.ts +8 -0
  39. package/src/policy/module-business-baseline.ts +12 -0
  40. package/src/registry/client.ts +9 -0
  41. package/src/services/alerting/observed-health.ts +71 -0
  42. package/src/services/api-principal-enrolment.test.ts +179 -0
  43. package/src/services/api-principal-enrolment.ts +103 -0
  44. package/src/services/audit/backups.ts +10 -1
  45. package/src/services/backup-metadata.ts +19 -11
  46. package/src/services/consumer-cleanup.ts +31 -5
  47. package/src/services/instance-ops.test.ts +302 -0
  48. package/src/services/instance-ops.ts +292 -0
  49. package/src/services/module-instances.test.ts +428 -42
  50. package/src/services/module-instances.ts +219 -26
@@ -563,6 +563,14 @@ export type ModuleSubscription = z.infer<typeof ModuleSubscriptionSchema>;
563
563
  * with via `celilo_contract`. The contract version determines the
564
564
  * canonical inputs/outputs of every lifecycle hook (see `./contracts/v1.ts`).
565
565
  */
566
+ /**
567
+ * Codepoints carrying Unicode's `Emoji` property, which the `icon` field
568
+ * rejects. Note this property is broader than "looks like an emoji": ASCII
569
+ * digits, `#` and `*` carry it too, because they form keycap sequences. That
570
+ * over-rejection costs nothing — none of them is a plausible module glyph.
571
+ */
572
+ const EMOJI_CODEPOINT = /\p{Emoji}/u;
573
+
566
574
  export const ModuleManifestSchema = z
567
575
  .object({
568
576
  /**
@@ -584,6 +592,55 @@ export const ModuleManifestSchema = z
584
592
  version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semantic version (e.g., 1.0.0)'),
585
593
  description: z.string().optional(),
586
594
 
595
+ /**
596
+ * One glyph identifying this module wherever celilo draws it — the console
597
+ * roster, the topology boxes, the registry browse page. Optional: a module
598
+ * declaring none falls back to the consumer's built-in table, then to a
599
+ * placeholder (openspec/changes/module-icons, D3/D5).
600
+ *
601
+ * Exactly one Unicode scalar, inside the BMP, without Unicode's `Emoji`
602
+ * property. These glyphs inherit the colour of the row they are drawn in,
603
+ * so a firing module's icon goes red with the rest of the row. An emoji
604
+ * codepoint paints its own colours and would stay cheerful while its
605
+ * module's state said otherwise.
606
+ *
607
+ * BMP-and-not-Emoji is a PROXY for "renders monochrome", not a proof. Some
608
+ * BMP codepoints outside the Emoji property still get an emoji font on some
609
+ * platforms. What it does catch is the whole SMP emoji range, which is
610
+ * where an author reaching for a padlock or a shield actually lands, and
611
+ * that is the case worth catching.
612
+ *
613
+ * `ModuleManifestSchema` is strict, so a celilo predating this field
614
+ * rejects a manifest declaring it. The CLI release accepting `icon` ships
615
+ * before any module published to the registry declares one (D2).
616
+ */
617
+ icon: z
618
+ .string()
619
+ .superRefine((value, ctx) => {
620
+ const scalars = [...value];
621
+ if (scalars.length !== 1) {
622
+ ctx.addIssue({
623
+ code: z.ZodIssueCode.custom,
624
+ message: `icon must be exactly one character, got ${scalars.length}`,
625
+ });
626
+ return;
627
+ }
628
+ const codePoint = value.codePointAt(0) ?? 0;
629
+ if (codePoint > 0xffff || EMOJI_CODEPOINT.test(value)) {
630
+ const hex = codePoint.toString(16).toUpperCase().padStart(4, '0');
631
+ ctx.addIssue({
632
+ code: z.ZodIssueCode.custom,
633
+ message: [
634
+ `icon '${value}' (U+${hex}) must be a monochrome glyph: it inherits the colour`,
635
+ 'of the row it is drawn in, and an emoji codepoint paints its own colours, so it',
636
+ "would stay cheerful while the module went red. Use a BMP symbol outside Unicode's",
637
+ "Emoji property (a key '\u26bf', not a padlock '\u{1f512}').",
638
+ ].join(' '),
639
+ });
640
+ }
641
+ })
642
+ .optional(),
643
+
587
644
  /**
588
645
  * How `manifest.yml#version` (the PAYLOAD version) is determined — see
589
646
  * openspec/changes/module-version-semantics/proposal.md / ISS-0151. The capability *contract* version
@@ -608,6 +665,41 @@ export const ModuleManifestSchema = z
608
665
  .strict()
609
666
  .optional(),
610
667
 
668
+ /**
669
+ * Submodules this module owns (openspec/changes/submodules, D1).
670
+ *
671
+ * Each name is a directory under this module's own `submodules/`, holding
672
+ * an ordinary module manifest. A submodule is never a top-level module: an
673
+ * operator does not import it, does not deploy it, and never sees it in the
674
+ * registry. It ships inside this package, versions with this manifest, and
675
+ * exists only as instances this module creates through the
676
+ * `celilo_module_deploy_worker` capability.
677
+ *
678
+ * Ownership is declared by the PARENT rather than flagged on the child, and
679
+ * that is what makes the exclusivity structural rather than enforced. A
680
+ * submodule is not in `modules/`, so there is nothing to deploy by accident
681
+ * and no flag for a code path to forget to check.
682
+ *
683
+ * A plain list of names, not a block. Per-submodule policy such as a
684
+ * maximum instance count is the operator's business rather than the
685
+ * author's — the same reason `requires.system` states a minimum rather than
686
+ * a deployed size. A `string | object` union stays reachable additively if
687
+ * a genuinely author-owned per-submodule fact ever turns up.
688
+ *
689
+ * v1.0 contract field — additive, no contract version bump needed, exactly
690
+ * like `optional.capabilities`.
691
+ */
692
+ submodules: z
693
+ .array(
694
+ z
695
+ .string()
696
+ .regex(
697
+ /^[a-z0-9]+(-[a-z0-9]+)*$/,
698
+ 'Submodule name must use kebab-case — it names a directory under submodules/',
699
+ ),
700
+ )
701
+ .optional(),
702
+
611
703
  requires: z
612
704
  .object({
613
705
  capabilities: z.array(CapabilityRequirementSchema).default([]),
@@ -7,6 +7,8 @@ import {
7
7
  validateHookContract,
8
8
  validateManifest,
9
9
  validateProvidesNoCrossCapabilityRefs,
10
+ validateSubmoduleDeclaration,
11
+ validateSubmoduleManifest,
10
12
  validateVariableSources,
11
13
  } from './validate';
12
14
 
@@ -1531,3 +1533,143 @@ base_module_aspect:
1531
1533
  expect(result.success).toBe(false);
1532
1534
  });
1533
1535
  });
1536
+
1537
+ describe('submodules (openspec/changes/submodules D1, D9)', () => {
1538
+ const parent = (over: Record<string, unknown> = {}) => ({
1539
+ celilo_contract: '1.0' as const,
1540
+ id: 'forgejo',
1541
+ name: 'Forgejo',
1542
+ version: '1.0.0',
1543
+ requires: { capabilities: [] },
1544
+ provides: { capabilities: [] },
1545
+ variables: { owns: [], imports: [] },
1546
+ ...over,
1547
+ });
1548
+
1549
+ const submodule = (over: Record<string, unknown> = {}) => ({
1550
+ celilo_contract: '1.0' as const,
1551
+ id: 'runner',
1552
+ name: 'Forgejo Runner',
1553
+ version: '1.0.0',
1554
+ requires: { capabilities: [] },
1555
+ provides: { capabilities: [] },
1556
+ variables: { owns: [], imports: [] },
1557
+ ...over,
1558
+ });
1559
+
1560
+ describe('validateSubmoduleDeclaration', () => {
1561
+ test('a module declaring no submodules is unaffected', () => {
1562
+ expect(validateSubmoduleDeclaration(parent())).toBeNull();
1563
+ });
1564
+
1565
+ test('accepts a plain list of names', () => {
1566
+ expect(validateSubmoduleDeclaration(parent({ submodules: ['runner'] }))).toBeNull();
1567
+ });
1568
+
1569
+ test('rejects a duplicate name', () => {
1570
+ const result = validateSubmoduleDeclaration(parent({ submodules: ['runner', 'runner'] }));
1571
+ expect(result).not.toBeNull();
1572
+ expect(result?.errors[0]?.message).toContain('more than once');
1573
+ });
1574
+
1575
+ test('rejects a submodule sharing its parent id', () => {
1576
+ const result = validateSubmoduleDeclaration(parent({ submodules: ['forgejo'] }));
1577
+ expect(result).not.toBeNull();
1578
+ expect(result?.errors[0]?.message).toContain("parent module's id");
1579
+ });
1580
+ });
1581
+
1582
+ describe('validateSubmoduleManifest', () => {
1583
+ test('accepts an ordinary submodule manifest', () => {
1584
+ expect(validateSubmoduleManifest(parent(), 'runner', submodule())).toBeNull();
1585
+ });
1586
+
1587
+ test('rejects an id that disagrees with its directory', () => {
1588
+ const result = validateSubmoduleManifest(parent(), 'runner', submodule({ id: 'builder' }));
1589
+ expect(result).not.toBeNull();
1590
+ expect(result?.errors[0]?.message).toContain('does not match its directory');
1591
+ });
1592
+
1593
+ // D9: a capability resolves to ONE provider, and celilo's two bespoke
1594
+ // multi-provider rules RESOLVE rather than fail, so N instances would bind
1595
+ // a consumer to whichever was created first — silently.
1596
+ test('rejects a submodule that provides a capability', () => {
1597
+ const result = validateSubmoduleManifest(
1598
+ parent(),
1599
+ 'runner',
1600
+ submodule({
1601
+ provides: { capabilities: [{ name: 'source_forge', version: '1.0.0', data: {} }] },
1602
+ }),
1603
+ );
1604
+ expect(result).not.toBeNull();
1605
+ expect(result?.errors[0]?.message).toContain('may not provide a capability');
1606
+ expect(result?.errors[0]?.message).toContain('source_forge');
1607
+ });
1608
+
1609
+ test('a submodule may still REQUIRE capabilities', () => {
1610
+ const result = validateSubmoduleManifest(
1611
+ parent(),
1612
+ 'runner',
1613
+ submodule({ requires: { capabilities: [{ name: 'source_forge', version: '1.0.0' }] } }),
1614
+ );
1615
+ expect(result).toBeNull();
1616
+ });
1617
+
1618
+ test('rejects nesting — ownership is one level deep', () => {
1619
+ const result = validateSubmoduleManifest(
1620
+ parent(),
1621
+ 'runner',
1622
+ submodule({ submodules: ['nested'] }),
1623
+ );
1624
+ expect(result).not.toBeNull();
1625
+ expect(result?.errors[0]?.message).toContain('may not declare submodules of its own');
1626
+ });
1627
+
1628
+ test('reports every problem at once rather than stopping at the first', () => {
1629
+ const result = validateSubmoduleManifest(
1630
+ parent(),
1631
+ 'runner',
1632
+ submodule({
1633
+ id: 'builder',
1634
+ provides: { capabilities: [{ name: 'source_forge', version: '1.0.0', data: {} }] },
1635
+ submodules: ['nested'],
1636
+ }),
1637
+ );
1638
+ expect(result?.errors).toHaveLength(3);
1639
+ });
1640
+ });
1641
+
1642
+ describe('schema', () => {
1643
+ test('parses a manifest declaring submodules', () => {
1644
+ const yaml = `
1645
+ celilo_contract: "1.0"
1646
+ id: forgejo
1647
+ name: Forgejo
1648
+ version: 1.0.0
1649
+ submodules:
1650
+ - runner
1651
+ requires:
1652
+ capabilities: []
1653
+ `;
1654
+ const result = validateManifest(yaml);
1655
+ expect(result.success).toBe(true);
1656
+ if (result.success) expect(result.data.submodules).toEqual(['runner']);
1657
+ });
1658
+
1659
+ test('rejects a non-kebab-case submodule name', () => {
1660
+ const yaml = `
1661
+ celilo_contract: "1.0"
1662
+ id: forgejo
1663
+ name: Forgejo
1664
+ version: 1.0.0
1665
+ submodules:
1666
+ - Runner_One
1667
+ requires:
1668
+ capabilities: []
1669
+ `;
1670
+ const result = validateManifest(yaml);
1671
+ expect(result.success).toBe(false);
1672
+ if (!result.success) expect(result.errors[0]?.message).toContain('kebab-case');
1673
+ });
1674
+ });
1675
+ });
@@ -1,6 +1,7 @@
1
1
  import { KNOWN_CAPABILITY_NAMES, isProviderView } from '@celilo/capabilities';
2
2
  import { parse as parseYaml } from 'yaml';
3
3
  import type { ZodError } from 'zod';
4
+ import { SUBMODULES_DIR } from '../module/packaging/package-rules';
4
5
  import { validateModuleZoneRequirements } from '../services/zone-policy';
5
6
  import { resolveContract, supportedContractVersions } from './contracts';
6
7
  import { ModuleManifestSchema, getSingularSystemSpec } from './schema';
@@ -495,3 +496,103 @@ export function validateProvidesNoCrossCapabilityRefs(
495
496
 
496
497
  return null;
497
498
  }
499
+
500
+ /**
501
+ * The directory a parent's submodules live in, relative to the parent's own
502
+ * source root. Re-exported rather than redefined: the packaging rules need the
503
+ * same name and `package-rules.ts` is deliberately dependency-free (the
504
+ * registry server imports it), so it owns the definition and everything
505
+ * manifest-facing reads it from here.
506
+ */
507
+ export { SUBMODULES_DIR };
508
+
509
+ /**
510
+ * Validate a PARENT's `submodules` declaration.
511
+ *
512
+ * Policy function - checks the declaration itself, not the submodules it names.
513
+ * Reading those is I/O and belongs to the caller.
514
+ *
515
+ * @param manifest - Validated manifest of the parent
516
+ * @returns Validation errors if any, null if clean
517
+ */
518
+ export function validateSubmoduleDeclaration(manifest: ModuleManifest): ValidationError | null {
519
+ const declared = manifest.submodules ?? [];
520
+ if (declared.length === 0) return null;
521
+
522
+ const errors: Array<{ path: string; message: string }> = [];
523
+ const seen = new Set<string>();
524
+
525
+ for (const [index, name] of declared.entries()) {
526
+ if (seen.has(name)) {
527
+ errors.push({
528
+ path: `submodules.${index}`,
529
+ message: `Submodule '${name}' is declared more than once`,
530
+ });
531
+ }
532
+ seen.add(name);
533
+
534
+ // The derived id of an instance is built from the parent and the submodule
535
+ // name, so a submodule sharing its parent's id yields an instance id that
536
+ // reads as the parent's own. Cheap to refuse, confusing to debug.
537
+ if (name === manifest.id) {
538
+ errors.push({
539
+ path: `submodules.${index}`,
540
+ message: `Submodule '${name}' cannot share its parent module's id`,
541
+ });
542
+ }
543
+ }
544
+
545
+ return errors.length > 0 ? { success: false, errors } : null;
546
+ }
547
+
548
+ /**
549
+ * Validate that a SUBMODULE's own manifest is legal as a submodule.
550
+ *
551
+ * Policy function - the caller has already read and schema-validated the
552
+ * submodule's manifest; this decides whether it may be one.
553
+ *
554
+ * @param parent - The declaring parent's manifest
555
+ * @param submoduleName - The name the parent declared, i.e. its directory
556
+ * @param submodule - The submodule's own validated manifest
557
+ * @returns Validation errors if any, null if clean
558
+ */
559
+ export function validateSubmoduleManifest(
560
+ parent: ModuleManifest,
561
+ submoduleName: string,
562
+ submodule: ModuleManifest,
563
+ ): ValidationError | null {
564
+ const errors: Array<{ path: string; message: string }> = [];
565
+ const where = `${SUBMODULES_DIR}/${submoduleName}/manifest.yml`;
566
+
567
+ if (submodule.id !== submoduleName) {
568
+ errors.push({
569
+ path: `${where}#id`,
570
+ message: `Submodule id '${submodule.id}' does not match its directory '${submoduleName}'. A submodule is addressed by the name its parent declares, so the two must agree.`,
571
+ });
572
+ }
573
+
574
+ // D9: celilo has no general rule for resolving a capability name to one of
575
+ // several providers. The two bespoke rules that exist RESOLVE rather than
576
+ // fail — `firewall` builds a chain, `dns_registrar` takes the first row — so
577
+ // N instances registering one capability would silently bind a consumer to
578
+ // whichever was created first. Refused here rather than discovered there.
579
+ if ((submodule.provides?.capabilities ?? []).length > 0) {
580
+ const names = submodule.provides.capabilities.map((c) => c.name).join(', ');
581
+ errors.push({
582
+ path: `${where}#provides.capabilities`,
583
+ message: `A submodule may not provide a capability (declares: ${names}). A capability resolves to ONE provider, and a submodule exists as many instances, so a consumer would bind to whichever instance happened to be created first. Move the capability to '${parent.id}'.`,
584
+ });
585
+ }
586
+
587
+ // One level only. D4's instance layout puts every instance flat under
588
+ // `modules/`, and nesting would make a derived id ambiguous about which
589
+ // ancestor owns it.
590
+ if ((submodule.submodules ?? []).length > 0) {
591
+ errors.push({
592
+ path: `${where}#submodules`,
593
+ message: `A submodule may not declare submodules of its own. Ownership is one level deep: '${parent.id}' owns '${submoduleName}', and nothing owns anything below that.`,
594
+ });
595
+ }
596
+
597
+ return errors.length > 0 ? { success: false, errors } : null;
598
+ }
@@ -171,6 +171,122 @@ version: 1.0.0
171
171
  });
172
172
  });
173
173
 
174
+ describe('submodules (openspec/changes/submodules D1)', () => {
175
+ const PARENT = `
176
+ celilo_contract: "1.0"
177
+ id: forgejo
178
+ name: Forgejo
179
+ version: 1.0.0
180
+ submodules:
181
+ - runner
182
+ `;
183
+ const SUBMODULE = `
184
+ celilo_contract: "1.0"
185
+ id: runner
186
+ name: Forgejo Runner
187
+ version: 1.0.0
188
+ `;
189
+
190
+ async function writeParent(dirName: string, parent: string, submodule?: string) {
191
+ const moduleDir = join(TEST_FIXTURES_DIR, dirName);
192
+ await mkdir(moduleDir, { recursive: true });
193
+ await writeFile(join(moduleDir, 'manifest.yml'), parent);
194
+ if (submodule !== undefined) {
195
+ const subDir = join(moduleDir, 'submodules', 'runner');
196
+ await mkdir(subDir, { recursive: true });
197
+ await writeFile(join(subDir, 'manifest.yml'), submodule);
198
+ }
199
+ return moduleDir;
200
+ }
201
+
202
+ test('accepts a parent whose declared submodule is present and legal', async () => {
203
+ const dir = await writeParent('forgejo-ok', PARENT, SUBMODULE);
204
+ const result = await readModuleManifest(dir);
205
+ expect(result.success).toBe(true);
206
+ if (result.success) expect(result.manifest.submodules).toEqual(['runner']);
207
+ });
208
+
209
+ // The point of validating at PARENT import: an instantiation happens with
210
+ // no operator present, which is the worst moment to learn a manifest is
211
+ // broken.
212
+ test('rejects a parent declaring a submodule that does not exist', async () => {
213
+ const dir = await writeParent('forgejo-missing', PARENT);
214
+ const result = await readModuleManifest(dir);
215
+ expect(result.success).toBe(false);
216
+ if (!result.success) {
217
+ expect(result.error).toContain('submodules/runner/manifest.yml does not exist');
218
+ }
219
+ });
220
+
221
+ test('rejects a parent whose submodule provides a capability', async () => {
222
+ const dir = await writeParent(
223
+ 'forgejo-provides',
224
+ PARENT,
225
+ `${SUBMODULE}
226
+ provides:
227
+ capabilities:
228
+ - name: source_forge
229
+ version: 1.0.0
230
+ data: {}
231
+ `,
232
+ );
233
+ const result = await readModuleManifest(dir);
234
+ expect(result.success).toBe(false);
235
+ if (!result.success) expect(result.error).toContain('may not provide a capability');
236
+ });
237
+
238
+ test('rejects a submodule whose id disagrees with its directory', async () => {
239
+ const dir = await writeParent(
240
+ 'forgejo-mismatch',
241
+ PARENT,
242
+ SUBMODULE.replace('id: runner', 'id: builder'),
243
+ );
244
+ const result = await readModuleManifest(dir);
245
+ expect(result.success).toBe(false);
246
+ if (!result.success) expect(result.error).toContain('does not match its directory');
247
+ });
248
+
249
+ test('a module declaring no submodules is unaffected', async () => {
250
+ const dir = await writeParent(
251
+ 'plain-module',
252
+ `
253
+ celilo_contract: "1.0"
254
+ id: plain
255
+ name: Plain
256
+ version: 1.0.0
257
+ `,
258
+ );
259
+ const result = await readModuleManifest(dir);
260
+ expect(result.success).toBe(true);
261
+ });
262
+
263
+ // A submodule reaches the fleet inside its parent's package. The registry
264
+ // path is already closed (submodules are never published separately), so a
265
+ // local path is the only open door and this is what shuts it.
266
+ test('refuses to import a submodule directory on its own, naming the parent', async () => {
267
+ await writeParent('forgejo-direct', PARENT, SUBMODULE);
268
+ const submodulePath = join(TEST_FIXTURES_DIR, 'forgejo-direct', 'submodules', 'runner');
269
+
270
+ const error = validateModuleDirectory(submodulePath);
271
+
272
+ expect(error).not.toBeNull();
273
+ expect(error).toContain("is a submodule of 'forgejo-direct'");
274
+ expect(error).toContain('Import');
275
+ });
276
+
277
+ test('a trailing slash does not defeat the submodule refusal', async () => {
278
+ await writeParent('forgejo-slash', PARENT, SUBMODULE);
279
+ const submodulePath = `${join(TEST_FIXTURES_DIR, 'forgejo-slash', 'submodules', 'runner')}/`;
280
+
281
+ expect(validateModuleDirectory(submodulePath)).toContain('is a submodule of');
282
+ });
283
+
284
+ test('an ordinary module directory is still importable', async () => {
285
+ const dir = await writeParent('ordinary', PARENT, SUBMODULE);
286
+ expect(validateModuleDirectory(dir)).toBeNull();
287
+ });
288
+ });
289
+
174
290
  describe('copyModuleFiles', () => {
175
291
  test('should copy all files from source to target', async () => {
176
292
  const sourceDir = join(TEST_FIXTURES_DIR, 'source');
@@ -1,7 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { existsSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { copyFile, mkdir, readFile, readdir } from 'node:fs/promises';
4
- import { join, relative } from 'node:path';
4
+ import { basename, dirname, join, relative } from 'node:path';
5
5
  import { eq } from 'drizzle-orm';
6
6
  import { z } from 'zod';
7
7
  import { getWellKnownCapability, isWellKnown } from '../capabilities/well-known';
@@ -12,12 +12,15 @@ import { capabilities, moduleIntegrity, modules } from '../db/schema';
12
12
  import type { NewModule, NewModuleIntegrity } from '../db/schema';
13
13
  import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
14
14
  import {
15
+ SUBMODULES_DIR,
15
16
  validateCapabilityNames,
16
17
  validateDeriveFromSources,
17
18
  validateHookContract,
18
19
  validateManifest,
19
20
  validatePrivilegedCapabilities,
20
21
  validateProvidesNoCrossCapabilityRefs,
22
+ validateSubmoduleDeclaration,
23
+ validateSubmoduleManifest,
21
24
  validateVariableSources,
22
25
  validateZoneRequirements,
23
26
  } from '../manifest/validate';
@@ -93,6 +96,17 @@ export function validateModuleDirectory(sourcePath: string): string | null {
93
96
  return `Module directory does not exist: ${sourcePath}`;
94
97
  }
95
98
 
99
+ // A submodule is not a module (design D1). It reaches the fleet inside its
100
+ // parent's package and exists only as instances the parent creates, so
101
+ // importing one on its own would produce a top-level module that was never
102
+ // written to be singular. The registry path is already closed — submodules
103
+ // are never published separately — so a local path is the only open door.
104
+ const parentDir = dirname(sourcePath.replace(/\/+$/, ''));
105
+ if (basename(parentDir) === SUBMODULES_DIR) {
106
+ const owner = basename(dirname(parentDir));
107
+ return `'${basename(sourcePath)}' is a submodule of '${owner}', not a module. Import '${owner}' instead — its package carries this submodule, and only '${owner}' can instantiate it.`;
108
+ }
109
+
96
110
  // Check it's a directory
97
111
  const stats = statSync(sourcePath);
98
112
  if (!stats.isDirectory()) {
@@ -208,12 +222,70 @@ export async function readModuleManifest(
208
222
  };
209
223
  }
210
224
 
225
+ const submoduleCheck = await validateDeclaredSubmodules(sourcePath, validationResult.data);
226
+ if (submoduleCheck) {
227
+ return { success: false, error: submoduleCheck };
228
+ }
229
+
211
230
  return {
212
231
  success: true,
213
232
  manifest: validationResult.data,
214
233
  };
215
234
  }
216
235
 
236
+ /**
237
+ * Read and validate every submodule a parent declares.
238
+ *
239
+ * Runs at PARENT IMPORT so a broken submodule fails the parent, rather than
240
+ * surfacing at the first instantiation — which happens with no operator
241
+ * present and is the worst possible moment to learn a manifest is wrong
242
+ * (design D1).
243
+ *
244
+ * @param sourcePath - The parent's module directory
245
+ * @param parent - The parent's validated manifest
246
+ * @returns An error message if any submodule is missing or illegal, null if clean
247
+ */
248
+ async function validateDeclaredSubmodules(
249
+ sourcePath: string,
250
+ parent: ModuleManifest,
251
+ ): Promise<string | null> {
252
+ const declarationCheck = validateSubmoduleDeclaration(parent);
253
+ if (declarationCheck) {
254
+ const errorMessages = declarationCheck.errors.map((e) => `${e.path}: ${e.message}`).join('\n');
255
+ return `Submodule declaration validation failed:\n${errorMessages}`;
256
+ }
257
+
258
+ for (const name of parent.submodules ?? []) {
259
+ const submodulePath = join(sourcePath, SUBMODULES_DIR, name);
260
+ const manifestPath = join(submodulePath, 'manifest.yml');
261
+
262
+ if (!existsSync(manifestPath)) {
263
+ return `Module '${parent.id}' declares submodule '${name}', but ${SUBMODULES_DIR}/${name}/manifest.yml does not exist.`;
264
+ }
265
+
266
+ let yamlContent: string;
267
+ try {
268
+ yamlContent = await readFile(manifestPath, 'utf-8');
269
+ } catch (error) {
270
+ return `Failed to read ${SUBMODULES_DIR}/${name}/manifest.yml: ${error instanceof Error ? error.message : 'Unknown error'}`;
271
+ }
272
+
273
+ const parsed = validateManifest(yamlContent);
274
+ if (!parsed.success) {
275
+ const errorMessages = parsed.errors.map((e) => `${e.path}: ${e.message}`).join(', ');
276
+ return `Submodule '${name}' has an invalid manifest: ${errorMessages}`;
277
+ }
278
+
279
+ const submoduleCheck = validateSubmoduleManifest(parent, name, parsed.data);
280
+ if (submoduleCheck) {
281
+ const errorMessages = submoduleCheck.errors.map((e) => `${e.path}: ${e.message}`).join('\n');
282
+ return `Submodule '${name}' validation failed:\n${errorMessages}`;
283
+ }
284
+ }
285
+
286
+ return null;
287
+ }
288
+
217
289
  /**
218
290
  * Copy module files to target directory
219
291
  *