@endora-commerce/contracts 0.100.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 (327) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/dist/actor.d.ts +79 -0
  4. package/dist/actor.d.ts.map +1 -0
  5. package/dist/actor.js +41 -0
  6. package/dist/actor.js.map +1 -0
  7. package/dist/addresses.d.ts +134 -0
  8. package/dist/addresses.d.ts.map +1 -0
  9. package/dist/addresses.js +16 -0
  10. package/dist/addresses.js.map +1 -0
  11. package/dist/admin-actions.d.ts +367 -0
  12. package/dist/admin-actions.d.ts.map +1 -0
  13. package/dist/admin-actions.js +287 -0
  14. package/dist/admin-actions.js.map +1 -0
  15. package/dist/admin-contributions.d.ts +518 -0
  16. package/dist/admin-contributions.d.ts.map +1 -0
  17. package/dist/admin-contributions.js +495 -0
  18. package/dist/admin-contributions.js.map +1 -0
  19. package/dist/admin-i18n.d.ts +135 -0
  20. package/dist/admin-i18n.d.ts.map +1 -0
  21. package/dist/admin-i18n.js +72 -0
  22. package/dist/admin-i18n.js.map +1 -0
  23. package/dist/admin-notifications.d.ts +55 -0
  24. package/dist/admin-notifications.d.ts.map +1 -0
  25. package/dist/admin-notifications.js +16 -0
  26. package/dist/admin-notifications.js.map +1 -0
  27. package/dist/admin-roles.d.ts +125 -0
  28. package/dist/admin-roles.d.ts.map +1 -0
  29. package/dist/admin-roles.js +2 -0
  30. package/dist/admin-roles.js.map +1 -0
  31. package/dist/admin-users.d.ts +178 -0
  32. package/dist/admin-users.d.ts.map +1 -0
  33. package/dist/admin-users.js +14 -0
  34. package/dist/admin-users.js.map +1 -0
  35. package/dist/admin.d.ts +243 -0
  36. package/dist/admin.d.ts.map +1 -0
  37. package/dist/admin.js +246 -0
  38. package/dist/admin.js.map +1 -0
  39. package/dist/analytics.d.ts +123 -0
  40. package/dist/analytics.d.ts.map +1 -0
  41. package/dist/analytics.js +68 -0
  42. package/dist/analytics.js.map +1 -0
  43. package/dist/api-keys.d.ts +97 -0
  44. package/dist/api-keys.d.ts.map +1 -0
  45. package/dist/api-keys.js +64 -0
  46. package/dist/api-keys.js.map +1 -0
  47. package/dist/assets-library.d.ts +684 -0
  48. package/dist/assets-library.d.ts.map +1 -0
  49. package/dist/assets-library.js +181 -0
  50. package/dist/assets-library.js.map +1 -0
  51. package/dist/audit-logs.d.ts +141 -0
  52. package/dist/audit-logs.d.ts.map +1 -0
  53. package/dist/audit-logs.js +31 -0
  54. package/dist/audit-logs.js.map +1 -0
  55. package/dist/auth.d.ts +174 -0
  56. package/dist/auth.d.ts.map +1 -0
  57. package/dist/auth.js +27 -0
  58. package/dist/auth.js.map +1 -0
  59. package/dist/blog.d.ts +669 -0
  60. package/dist/blog.d.ts.map +1 -0
  61. package/dist/blog.js +360 -0
  62. package/dist/blog.js.map +1 -0
  63. package/dist/capabilities.d.ts +40 -0
  64. package/dist/capabilities.d.ts.map +1 -0
  65. package/dist/capabilities.js +38 -0
  66. package/dist/capabilities.js.map +1 -0
  67. package/dist/carts.d.ts +1367 -0
  68. package/dist/carts.d.ts.map +1 -0
  69. package/dist/carts.js +405 -0
  70. package/dist/carts.js.map +1 -0
  71. package/dist/catalog.d.ts +2855 -0
  72. package/dist/catalog.d.ts.map +1 -0
  73. package/dist/catalog.js +1543 -0
  74. package/dist/catalog.js.map +1 -0
  75. package/dist/cms.d.ts +872 -0
  76. package/dist/cms.d.ts.map +1 -0
  77. package/dist/cms.js +468 -0
  78. package/dist/cms.js.map +1 -0
  79. package/dist/common.d.ts +82 -0
  80. package/dist/common.d.ts.map +1 -0
  81. package/dist/common.js +72 -0
  82. package/dist/common.js.map +1 -0
  83. package/dist/comparisons.d.ts +487 -0
  84. package/dist/comparisons.d.ts.map +1 -0
  85. package/dist/comparisons.js +221 -0
  86. package/dist/comparisons.js.map +1 -0
  87. package/dist/credentials.d.ts +292 -0
  88. package/dist/credentials.d.ts.map +1 -0
  89. package/dist/credentials.js +142 -0
  90. package/dist/credentials.js.map +1 -0
  91. package/dist/credit-limits.d.ts +111 -0
  92. package/dist/credit-limits.d.ts.map +1 -0
  93. package/dist/credit-limits.js +35 -0
  94. package/dist/credit-limits.js.map +1 -0
  95. package/dist/currencies.d.ts +127 -0
  96. package/dist/currencies.d.ts.map +1 -0
  97. package/dist/currencies.js +20 -0
  98. package/dist/currencies.js.map +1 -0
  99. package/dist/custom-fields.d.ts +345 -0
  100. package/dist/custom-fields.d.ts.map +1 -0
  101. package/dist/custom-fields.js +185 -0
  102. package/dist/custom-fields.js.map +1 -0
  103. package/dist/customer-accounts.d.ts +690 -0
  104. package/dist/customer-accounts.d.ts.map +1 -0
  105. package/dist/customer-accounts.js +41 -0
  106. package/dist/customer-accounts.js.map +1 -0
  107. package/dist/customers.d.ts +305 -0
  108. package/dist/customers.d.ts.map +1 -0
  109. package/dist/customers.js +158 -0
  110. package/dist/customers.js.map +1 -0
  111. package/dist/dictionary.d.ts +580 -0
  112. package/dist/dictionary.d.ts.map +1 -0
  113. package/dist/dictionary.js +297 -0
  114. package/dist/dictionary.js.map +1 -0
  115. package/dist/email-address.d.ts +62 -0
  116. package/dist/email-address.d.ts.map +1 -0
  117. package/dist/email-address.js +64 -0
  118. package/dist/email-address.js.map +1 -0
  119. package/dist/email.d.ts +175 -0
  120. package/dist/email.d.ts.map +1 -0
  121. package/dist/email.js +45 -0
  122. package/dist/email.js.map +1 -0
  123. package/dist/envelopes.d.ts +15 -0
  124. package/dist/envelopes.d.ts.map +1 -0
  125. package/dist/envelopes.js +16 -0
  126. package/dist/envelopes.js.map +1 -0
  127. package/dist/environment-inputs.d.ts +306 -0
  128. package/dist/environment-inputs.d.ts.map +1 -0
  129. package/dist/environment-inputs.js +277 -0
  130. package/dist/environment-inputs.js.map +1 -0
  131. package/dist/erp-connector.d.ts +52 -0
  132. package/dist/erp-connector.d.ts.map +1 -0
  133. package/dist/erp-connector.js +34 -0
  134. package/dist/erp-connector.js.map +1 -0
  135. package/dist/errors.d.ts +455 -0
  136. package/dist/errors.d.ts.map +1 -0
  137. package/dist/errors.js +532 -0
  138. package/dist/errors.js.map +1 -0
  139. package/dist/google-analytics.d.ts +181 -0
  140. package/dist/google-analytics.d.ts.map +1 -0
  141. package/dist/google-analytics.js +176 -0
  142. package/dist/google-analytics.js.map +1 -0
  143. package/dist/google-tag-manager.d.ts +111 -0
  144. package/dist/google-tag-manager.d.ts.map +1 -0
  145. package/dist/google-tag-manager.js +129 -0
  146. package/dist/google-tag-manager.js.map +1 -0
  147. package/dist/i18n.d.ts +69 -0
  148. package/dist/i18n.d.ts.map +1 -0
  149. package/dist/i18n.js +59 -0
  150. package/dist/i18n.js.map +1 -0
  151. package/dist/import-export.d.ts +63 -0
  152. package/dist/import-export.d.ts.map +1 -0
  153. package/dist/import-export.js +37 -0
  154. package/dist/import-export.js.map +1 -0
  155. package/dist/index.d.ts +82 -0
  156. package/dist/index.d.ts.map +1 -0
  157. package/dist/index.js +126 -0
  158. package/dist/index.js.map +1 -0
  159. package/dist/inventory.d.ts +673 -0
  160. package/dist/inventory.d.ts.map +1 -0
  161. package/dist/inventory.js +412 -0
  162. package/dist/inventory.js.map +1 -0
  163. package/dist/invoice-ledger.d.ts +366 -0
  164. package/dist/invoice-ledger.d.ts.map +1 -0
  165. package/dist/invoice-ledger.js +114 -0
  166. package/dist/invoice-ledger.js.map +1 -0
  167. package/dist/invoices.d.ts +845 -0
  168. package/dist/invoices.d.ts.map +1 -0
  169. package/dist/invoices.js +314 -0
  170. package/dist/invoices.js.map +1 -0
  171. package/dist/kernel.d.ts +49 -0
  172. package/dist/kernel.d.ts.map +1 -0
  173. package/dist/kernel.js +19 -0
  174. package/dist/kernel.js.map +1 -0
  175. package/dist/languages.d.ts +122 -0
  176. package/dist/languages.d.ts.map +1 -0
  177. package/dist/languages.js +24 -0
  178. package/dist/languages.js.map +1 -0
  179. package/dist/linkedin-ads.d.ts +167 -0
  180. package/dist/linkedin-ads.d.ts.map +1 -0
  181. package/dist/linkedin-ads.js +156 -0
  182. package/dist/linkedin-ads.js.map +1 -0
  183. package/dist/megamenu.d.ts +556 -0
  184. package/dist/megamenu.d.ts.map +1 -0
  185. package/dist/megamenu.js +186 -0
  186. package/dist/megamenu.js.map +1 -0
  187. package/dist/meta-ads.d.ts +126 -0
  188. package/dist/meta-ads.d.ts.map +1 -0
  189. package/dist/meta-ads.js +112 -0
  190. package/dist/meta-ads.js.map +1 -0
  191. package/dist/mfa.d.ts +274 -0
  192. package/dist/mfa.d.ts.map +1 -0
  193. package/dist/mfa.js +187 -0
  194. package/dist/mfa.js.map +1 -0
  195. package/dist/modules.d.ts +1706 -0
  196. package/dist/modules.d.ts.map +1 -0
  197. package/dist/modules.js +1390 -0
  198. package/dist/modules.js.map +1 -0
  199. package/dist/newsletter.d.ts +611 -0
  200. package/dist/newsletter.d.ts.map +1 -0
  201. package/dist/newsletter.js +345 -0
  202. package/dist/newsletter.js.map +1 -0
  203. package/dist/orders.d.ts +1175 -0
  204. package/dist/orders.d.ts.map +1 -0
  205. package/dist/orders.js +630 -0
  206. package/dist/orders.js.map +1 -0
  207. package/dist/organizations.d.ts +938 -0
  208. package/dist/organizations.d.ts.map +1 -0
  209. package/dist/organizations.js +418 -0
  210. package/dist/organizations.js.map +1 -0
  211. package/dist/pagination.d.ts +21 -0
  212. package/dist/pagination.d.ts.map +1 -0
  213. package/dist/pagination.js +22 -0
  214. package/dist/pagination.js.map +1 -0
  215. package/dist/payment-methods.d.ts +472 -0
  216. package/dist/payment-methods.d.ts.map +1 -0
  217. package/dist/payment-methods.js +175 -0
  218. package/dist/payment-methods.js.map +1 -0
  219. package/dist/payment-return-url.d.ts +53 -0
  220. package/dist/payment-return-url.d.ts.map +1 -0
  221. package/dist/payment-return-url.js +35 -0
  222. package/dist/payment-return-url.js.map +1 -0
  223. package/dist/payments.d.ts +386 -0
  224. package/dist/payments.d.ts.map +1 -0
  225. package/dist/payments.js +84 -0
  226. package/dist/payments.js.map +1 -0
  227. package/dist/pim-connector.d.ts +60 -0
  228. package/dist/pim-connector.d.ts.map +1 -0
  229. package/dist/pim-connector.js +43 -0
  230. package/dist/pim-connector.js.map +1 -0
  231. package/dist/pim-field-path.d.ts +6 -0
  232. package/dist/pim-field-path.d.ts.map +1 -0
  233. package/dist/pim-field-path.js +101 -0
  234. package/dist/pim-field-path.js.map +1 -0
  235. package/dist/platform-language.d.ts +20 -0
  236. package/dist/platform-language.d.ts.map +1 -0
  237. package/dist/platform-language.js +22 -0
  238. package/dist/platform-language.js.map +1 -0
  239. package/dist/price-lists.d.ts +685 -0
  240. package/dist/price-lists.d.ts.map +1 -0
  241. package/dist/price-lists.js +330 -0
  242. package/dist/price-lists.js.map +1 -0
  243. package/dist/product-feeds.d.ts +2837 -0
  244. package/dist/product-feeds.d.ts.map +1 -0
  245. package/dist/product-feeds.js +1504 -0
  246. package/dist/product-feeds.js.map +1 -0
  247. package/dist/product-scope-overrides.d.ts +134 -0
  248. package/dist/product-scope-overrides.d.ts.map +1 -0
  249. package/dist/product-scope-overrides.js +82 -0
  250. package/dist/product-scope-overrides.js.map +1 -0
  251. package/dist/product-value-resolver.d.ts +88 -0
  252. package/dist/product-value-resolver.d.ts.map +1 -0
  253. package/dist/product-value-resolver.js +128 -0
  254. package/dist/product-value-resolver.js.map +1 -0
  255. package/dist/promotions.d.ts +678 -0
  256. package/dist/promotions.d.ts.map +1 -0
  257. package/dist/promotions.js +479 -0
  258. package/dist/promotions.js.map +1 -0
  259. package/dist/prompt-actions.d.ts +582 -0
  260. package/dist/prompt-actions.d.ts.map +1 -0
  261. package/dist/prompt-actions.js +221 -0
  262. package/dist/prompt-actions.js.map +1 -0
  263. package/dist/pwa.d.ts +293 -0
  264. package/dist/pwa.d.ts.map +1 -0
  265. package/dist/pwa.js +204 -0
  266. package/dist/pwa.js.map +1 -0
  267. package/dist/quick-order.d.ts +340 -0
  268. package/dist/quick-order.d.ts.map +1 -0
  269. package/dist/quick-order.js +177 -0
  270. package/dist/quick-order.js.map +1 -0
  271. package/dist/quote-requests.d.ts +538 -0
  272. package/dist/quote-requests.d.ts.map +1 -0
  273. package/dist/quote-requests.js +308 -0
  274. package/dist/quote-requests.js.map +1 -0
  275. package/dist/returns.d.ts +774 -0
  276. package/dist/returns.d.ts.map +1 -0
  277. package/dist/returns.js +389 -0
  278. package/dist/returns.js.map +1 -0
  279. package/dist/sales-channels.d.ts +392 -0
  280. package/dist/sales-channels.d.ts.map +1 -0
  281. package/dist/sales-channels.js +285 -0
  282. package/dist/sales-channels.js.map +1 -0
  283. package/dist/scope-notice.d.ts +60 -0
  284. package/dist/scope-notice.d.ts.map +1 -0
  285. package/dist/scope-notice.js +56 -0
  286. package/dist/scope-notice.js.map +1 -0
  287. package/dist/search.d.ts +321 -0
  288. package/dist/search.d.ts.map +1 -0
  289. package/dist/search.js +160 -0
  290. package/dist/search.js.map +1 -0
  291. package/dist/seo.d.ts +113 -0
  292. package/dist/seo.d.ts.map +1 -0
  293. package/dist/seo.js +63 -0
  294. package/dist/seo.js.map +1 -0
  295. package/dist/settings.d.ts +453 -0
  296. package/dist/settings.d.ts.map +1 -0
  297. package/dist/settings.js +337 -0
  298. package/dist/settings.js.map +1 -0
  299. package/dist/shipments.d.ts +140 -0
  300. package/dist/shipments.d.ts.map +1 -0
  301. package/dist/shipments.js +14 -0
  302. package/dist/shipments.js.map +1 -0
  303. package/dist/shipping-methods.d.ts +350 -0
  304. package/dist/shipping-methods.d.ts.map +1 -0
  305. package/dist/shipping-methods.js +99 -0
  306. package/dist/shipping-methods.js.map +1 -0
  307. package/dist/shopping-lists.d.ts +122 -0
  308. package/dist/shopping-lists.d.ts.map +1 -0
  309. package/dist/shopping-lists.js +92 -0
  310. package/dist/shopping-lists.js.map +1 -0
  311. package/dist/taxes.d.ts +106 -0
  312. package/dist/taxes.d.ts.map +1 -0
  313. package/dist/taxes.js +80 -0
  314. package/dist/taxes.js.map +1 -0
  315. package/dist/text-normalization.d.ts +199 -0
  316. package/dist/text-normalization.d.ts.map +1 -0
  317. package/dist/text-normalization.js +205 -0
  318. package/dist/text-normalization.js.map +1 -0
  319. package/dist/transactional-emails.d.ts +459 -0
  320. package/dist/transactional-emails.d.ts.map +1 -0
  321. package/dist/transactional-emails.js +212 -0
  322. package/dist/transactional-emails.js.map +1 -0
  323. package/dist/webhooks.d.ts +69 -0
  324. package/dist/webhooks.d.ts.map +1 -0
  325. package/dist/webhooks.js +53 -0
  326. package/dist/webhooks.js.map +1 -0
  327. package/package.json +46 -0
@@ -0,0 +1,1390 @@
1
+ // Module Lifecycle — feature 018 contract surface.
2
+ //
3
+ // Defines the on-disk-data shape every module's `manifest.ts` exports plus
4
+ // the registry-record shape persisted in `module_registrations`. The settings
5
+ // portion (per-module groups + settings) is delegated to feature 004's
6
+ // existing `ModuleSettingsManifestSchema`; this module wraps it with the
7
+ // outer module-level metadata (id, name, version, dependencies) and the
8
+ // lifecycle-hook type aliases.
9
+ //
10
+ // Hooks themselves are NOT validated by Zod (functions don't serialise
11
+ // through schemas); the loader attaches them from the manifest module's
12
+ // runtime exports as a separate step.
13
+ import { z } from 'zod';
14
+ import { ModuleSettingsManifestSchema, settingCodeRe, } from './settings.js';
15
+ import { KnownIconNameSchema, ModuleActionsManifestSchema } from './admin-actions.js';
16
+ import { modulePermissionDeclarationSchema } from './admin.js';
17
+ import { errorCodeRe } from './errors.js';
18
+ import { capabilityKeyRe } from './capabilities.js';
19
+ import { transactionalEmailManifestEntrySchema } from './transactional-emails.js';
20
+ import { BlockCategorySchema, BlockDefinitionSchema, blockNameRe } from './cms.js';
21
+ import { EnvironmentInputSchema } from './environment-inputs.js';
22
+ // ---------------------------------------------------------------------------
23
+ // Identifier / version regexes
24
+ // ---------------------------------------------------------------------------
25
+ /**
26
+ * Module identifier — must equal the manifest file's parent folder name.
27
+ * Two-character ids are allowed (e.g. `_lifecycle` after underscore allowance).
28
+ * Underscore-prefixed ids are reserved for platform-internal modules
29
+ * (constitutional exemption alongside `auth` and `example`).
30
+ */
31
+ export const moduleIdRe = /^_?[a-z][a-z0-9_]*$/;
32
+ /** Semver-lite — `MAJOR.MINOR.PATCH` plus an optional `-prerelease` suffix. */
33
+ export const moduleVersionRe = /^\d+\.\d+\.\d+(?:-[a-z0-9.]+)?$/;
34
+ // ---------------------------------------------------------------------------
35
+ // Module manifest
36
+ // ---------------------------------------------------------------------------
37
+ /**
38
+ * Per-module Admin UI translation declaration (feature 019).
39
+ * When present, the lifecycle install hook reads
40
+ * `<modulePath>/<bundlesDir>/<lang>.json` for every supported Admin UI
41
+ * language and registers the bundle into `translation_bundles`. Default
42
+ * `bundlesDir` is `'i18n'` — every module that ships translations is
43
+ * expected to follow this convention.
44
+ */
45
+ export const ModuleI18nManifestSchema = z.object({
46
+ bundlesDir: z.string().min(1).default('i18n'),
47
+ });
48
+ /**
49
+ * Per-module documentation declaration (feature 100 / roadmap F12).
50
+ *
51
+ * The same shape as {@link ModuleI18nManifestSchema} and for the same reason: a
52
+ * directory at the **package root**, in the package's `files` list, with no
53
+ * `exports` subpath, located by joining `dir` to `dirname(manifestPath)`. The
54
+ * anchor is the platform's, so nothing in the module names a package, a
55
+ * repository root or a build directory in order to find its own pages
56
+ * (`specs/100-module-owned-documentation/contracts/module-documentation-layer.md`
57
+ * R2.1–R2.3).
58
+ *
59
+ * A declared directory that is not on disk is a **refusal**, naming the module —
60
+ * never "this module ships no documentation". That distinction is the whole of
61
+ * the repair `backend/src/manifest-locations.ts` was written for: the `_i18n`
62
+ * boot reconciler logs and skips an absent bundles directory, so a packaged
63
+ * module rendered every palette entry as a raw key with no error anywhere.
64
+ */
65
+ export const ModuleDocsManifestSchema = z.object({
66
+ /** The directory, relative to the module's own root. */
67
+ dir: z.string().min(1).default('docs'),
68
+ });
69
+ /**
70
+ * `docs: false` — this module ships no documentation, deliberately.
71
+ *
72
+ * **Absent and `false` are not the same state**, and the documentation check
73
+ * distinguishes them: absent is a module nobody has decided about, `false` is a
74
+ * decision. The argument is `check:bundle-pairing`'s, one population over — a
75
+ * universal obligation over a population where some members legitimately owe
76
+ * nothing is repaired by empty files whose only effect is to make a check pass.
77
+ * Some modules are infrastructure other modules consume and may honestly
78
+ * document nothing.
79
+ */
80
+ export const ModuleDocsDeclarationSchema = z.union([ModuleDocsManifestSchema, z.literal(false)]);
81
+ /**
82
+ * A function value the schema accepts by kind.
83
+ *
84
+ * `z.custom` rather than `z.function()`: Zod v4's function schema builds a
85
+ * validating *wrapper*, and this field must pass the author's own closure
86
+ * through by reference — the runner calls it, and a copy would be a second
87
+ * function nothing else in the tree holds.
88
+ */
89
+ function demoBodySchema(field) {
90
+ return z.custom((value) => typeof value === 'function', {
91
+ message: `demo.${field} must be a function. The body is reached by a relative ` +
92
+ `\`await import()\` from the declaration (contract §1.4), never by a path ` +
93
+ 'the platform is expected to guess.',
94
+ });
95
+ }
96
+ export const ModuleDemoManifestSchema = z.object({
97
+ summary: z.string().min(1).max(200),
98
+ seed: demoBodySchema('seed'),
99
+ reset: demoBodySchema('reset'),
100
+ after: z.array(z.string().regex(moduleIdRe)).readonly().optional(),
101
+ // §6.1. A **name**, so the schema is `z.string()` and deliberately carries no
102
+ // scope or prefix rule: `@endora-commerce/mod-<id>-demo` is this repository's
103
+ // convention and a third-party module's demo package is named by its author.
104
+ // A pattern here would be a derived fact written down (D-100) that answers
105
+ // wrongly for the first package that is not ours.
106
+ package: z.string().min(1).optional(),
107
+ });
108
+ /**
109
+ * `demo: false` — this module has nothing to demonstrate, deliberately.
110
+ *
111
+ * **Absent and `false` are not the same state** (§1.2). It is
112
+ * {@link ModuleDocsDeclarationSchema}'s rule and it exists for the same reason:
113
+ * a universal obligation over a population where some members legitimately owe
114
+ * nothing is repaired by empty files whose only effect is to make a check pass.
115
+ * `pim_connector` and `email` genuinely have nothing to show.
116
+ */
117
+ export const ModuleDemoDeclarationSchema = z.union([
118
+ ModuleDemoManifestSchema,
119
+ z.literal(false),
120
+ ]);
121
+ /**
122
+ * Operator-activation declaration — feature 073, Constitution XVII.
123
+ *
124
+ * The second of the two orthogonal presence axes. Platform availability lives
125
+ * in `module_registrations` and is owned by whoever operates the deployment;
126
+ * this block declares the *business* operator's control, which is an ordinary
127
+ * `Setting` row reconciled from the manifest.
128
+ *
129
+ * It used to sit beside a `license` tier, and the two were kept apart because
130
+ * conflating a build-time entitlement with a runtime toggle would make an
131
+ * operator's switch look like a licensing decision. That tier is gone (D-194
132
+ * removed the edition meta-packages it existed for), so activation is now the
133
+ * only presence declaration a manifest carries.
134
+ *
135
+ * Exactly one of the two forms is valid — enforced in `defineModuleManifest`
136
+ * rather than by the schema, because a Zod union of two non-strict objects
137
+ * accepts a value carrying both.
138
+ */
139
+ export const ModuleActivationSchema = z.union([
140
+ z.object({
141
+ /**
142
+ * The Setting that holds the operator's choice. Declared rather than
143
+ * derived so a module that already ships an ad-hoc control (`blog.enabled`
144
+ * and friends) can adopt it instead of growing a second switch.
145
+ */
146
+ settingCode: z.string().regex(settingCodeRe),
147
+ /** Applies when the operator has never chosen. Asserted, never assumed. */
148
+ default: z.boolean(),
149
+ }),
150
+ z.object({
151
+ /** The platform cannot run without this module. */
152
+ nonDeactivatable: z.literal(true),
153
+ /** Operator-facing sentence rendered next to the locked control. */
154
+ reason: z.string().min(1).max(200),
155
+ }),
156
+ ]);
157
+ /**
158
+ * A runtime dependency the declaring module deliberately keeps out of
159
+ * `dependencies` — feature 073, Amendment A1.
160
+ *
161
+ * The two are not two spellings of one thing. `dependencies` is read by the
162
+ * **topological install order** (`db/migration-order.ts`, the lifecycle's
163
+ * `ModuleDepGraph`), and a mutual pair declared there closes a cycle that fails
164
+ * the build: `addresses` must install after `organizations` because every
165
+ * stored address is organization-scoped, so `organizations` cannot also declare
166
+ * `addresses`, however real the port edge is. Withholding the declaration used
167
+ * to make the edge invisible to everything else too — the flip-time refusals
168
+ * saw no reason to stop an operator switching the owner off underneath a live
169
+ * resolver.
170
+ *
171
+ * So the edge is declared here instead: **read by the gating and refusal
172
+ * graph, ignored by the install order.** That is the whole trade, stated in the
173
+ * manifest that makes it rather than in a build script's constant, so a
174
+ * refusal and a CI check cannot drift apart on which edges exist.
175
+ */
176
+ export const ModuleAcknowledgedDependencySchema = z.object({
177
+ /** The module that owns the port. */
178
+ moduleId: z.string().regex(moduleIdRe),
179
+ /** The container registration name this module resolves, e.g. `addressService`. */
180
+ port: z.string().min(1),
181
+ /** Why the edge cannot be declared in `dependencies` — the cycle, spelled out. */
182
+ reason: z.string().min(1).max(800),
183
+ });
184
+ /**
185
+ * An edge that is real to the container but does not bind the operator — D-44.
186
+ *
187
+ * `dependencies` is read as three claims at once: install-and-migration order,
188
+ * "the container resolution is declared", and "an operator may not switch the
189
+ * owner off underneath me". `acknowledgedDependencies` withdraws the first.
190
+ * This withdraws the third, and only the third: a module declares here that it
191
+ * reads a name `moduleId` owns and that it has a defined behaviour when
192
+ * `moduleId` is not there, so the flip-time refusal has nothing to protect.
193
+ *
194
+ * Read by `check-port-dependencies.ts`, which needs the ownership claim and
195
+ * nothing else, and — when the deactivation-consequence dialog ships — by
196
+ * `/platform/modules`, which renders {@link whenAbsent}. Read by **nothing
197
+ * else**: not `ModuleDepGraph`, not `db/migration-order.ts`, and not
198
+ * `ModuleGatingGraph` in either direction. A cross-module foreign key therefore
199
+ * still forces a `dependencies` entry, and `fk-dependency-drift.test.ts` still
200
+ * fails for one declared here instead.
201
+ *
202
+ * The three kinds are the three ways an edge can exist without the bind bit:
203
+ *
204
+ * - **`contributes-to`** — the declaring module pushes an inert descriptor
205
+ * into `moduleId`'s ungated registry at boot. It has no failure mode in
206
+ * either direction: an absent contributor's descriptor is filtered by the
207
+ * host at enumeration, and an absent host's registry is a table nobody
208
+ * walks. `whenAbsent` is forbidden, because nothing degrades.
209
+ * - **`degrades-without`** — the declaring module reads an answer from
210
+ * `moduleId`, checks presence before it does, and keeps working with less.
211
+ * `whenAbsent` is required and states that behaviour, which is what an
212
+ * off-state test for the edge is held to.
213
+ * - **`refuses-without`** — the declaring module reads a **gated port**, has
214
+ * no fallback for it, and lets the 503 `MODULE_DISABLED` refusal reach the
215
+ * caller. The operation stops; the rest of the declaring module keeps
216
+ * working; the owner's activation control keeps working. `whenAbsent` is
217
+ * required and names **what** refuses, because that is the whole payload:
218
+ * the deactivation-consequence ledger classifies the edge `fails-closed`
219
+ * and the operator's confirmation dialog renders this sentence.
220
+ *
221
+ * The third kind was an omission rather than a narrowing, and it is worth
222
+ * saying why, because the gap is invisible from the manifest side. A read with
223
+ * no fallback had only one spelling — `dependencies` (or
224
+ * `acknowledgedDependencies`) — and both carry the bind, so a dependent that
225
+ * cannot itself be switched off turned the *owner's* activation control into a
226
+ * dead switch: the operator flips it, the flip-time refusal names a module
227
+ * that will never go away, and nothing happens. That is a worse answer than
228
+ * either alternative, since a control that lies is not a control. So the
229
+ * missing spelling is "refuse, and do not bind", which is what this kind is;
230
+ * the outcome it produces (`fails-closed`) has been in the ledger's vocabulary
231
+ * since feature 074 and was reachable only for edges that also bound.
232
+ *
233
+ * **The half of the claim about the owner's control is already unspellable**,
234
+ * and it is worth knowing where: rule 2 of `assertNonBindingRules` refuses any
235
+ * non-binding edge whose target the same manifest also names in
236
+ * `dependencies` or `acknowledgedDependencies` — one edge, one claim, in one
237
+ * place. So a `refuses-without` entry cannot sit beside the bind it denies;
238
+ * a module that wants both is telling the operator two things at once and is
239
+ * refused before the ledger ever sees it. `check-port-dependencies.ts` re-
240
+ * derives the same fact from the manifests as a second net, for a manifest
241
+ * built without this helper.
242
+ *
243
+ * The other two halves are the check's alone, because both are properties of
244
+ * the *tree* rather than of the manifest: the name is registered with
245
+ * `di.providePort` (an ungated registration has no refusal to propagate), and
246
+ * the resolution happens at call time (a gated port resolved at boot stops the
247
+ * next start rather than one request — the ledger's
248
+ * `gated-port-before-first-request`, which is assigned before any declaration
249
+ * is consulted and which no entry can therefore rescue).
250
+ *
251
+ * The fourth quadrant — order without bind — stays deliberately unspellable
252
+ * (Constitution IV). An edge that needs both goes back to `dependencies`, and
253
+ * the bind comes back with it.
254
+ */
255
+ export const ModuleNonBindingDependencySchema = z.object({
256
+ /** The module that owns the registration. */
257
+ moduleId: z.string().regex(moduleIdRe),
258
+ /** The container registration name, e.g. `promptActionToolRegistry`. */
259
+ name: z.string().min(1),
260
+ kind: z.enum(['contributes-to', 'degrades-without', 'refuses-without']),
261
+ /**
262
+ * `degrades-without` and `refuses-without` only: what stops working, and for
263
+ * the second, what refuses. Rendered beside the control.
264
+ */
265
+ whenAbsent: z.string().min(1).max(200).optional(),
266
+ reason: z.string().min(1).max(800),
267
+ });
268
+ /**
269
+ * One module a deployment knowingly does not ship — D-101's declared escape.
270
+ *
271
+ * A deployment may compose fewer modules than its manifests declare; what it may
272
+ * not do is arrive there silently, so the omission is declared in a committed,
273
+ * reviewed file (`backend/src/apps/<deployment>/divergence.ts`) and the boot
274
+ * refuses an omission that is not in it — or an entry for a module the
275
+ * deployment does ship, which is the same ledger read the other way.
276
+ *
277
+ * This is `ReducedDeploymentDeclaration` under its own name (D-205), and it is
278
+ * unchanged in substance: a module id, and a reason long enough to be an
279
+ * argument. What changed is where it sits — inside
280
+ * {@link DeploymentDivergenceDeclarationSchema}'s `omittedModules`, beside the
281
+ * other two things a deployment declares about itself.
282
+ */
283
+ export const OmittedModuleSchema = z.object({
284
+ /** The module this deployment does not ship. */
285
+ moduleId: z.string().regex(moduleIdRe),
286
+ /**
287
+ * Why — in prose, and long enough to be an argument. "We do not need it" is
288
+ * not a reason; what the deployment does instead of the capability is.
289
+ */
290
+ reason: z.string().min(20).max(800),
291
+ });
292
+ /**
293
+ * Everything a deployment declares about how it means to differ from core.
294
+ *
295
+ * `backend/src/apps/<deployment>/divergence.ts`, exporting `divergence`. The
296
+ * file was `reduced-deployment.ts` until it grew past omissions (D-205):
297
+ * *reduced* encodes a direction that is wrong for an addition, wrong for a
298
+ * substitution and wrong for an ordering, while `divergence` is already the
299
+ * word the generator's own header uses for the derived artefact beside it.
300
+ *
301
+ * The shape lives here rather than in `_lifecycle` because the file carrying it
302
+ * belongs to a **deployment**, and a deployment naming a module's internals is
303
+ * the coupling that outlives the module.
304
+ *
305
+ * **It holds judgement, ordering and prose — never population.** The single test
306
+ * for a field is whether the platform can derive it: the deployment's module
307
+ * list is the overlay walk's answer and the divergences themselves are the
308
+ * report's, so neither belongs here
309
+ * (`specs/107-override-report-and-ladder/contracts/deployment-declaration.md` §5).
310
+ *
311
+ * Every field defaults to empty, so a declaration that leaves one out means
312
+ * "none of these" rather than "unparseable" — the reading an absent file already
313
+ * gets. A deployment that diverges by nothing still ships the file with all
314
+ * three written out, because the mechanism is easier to find than to remember.
315
+ */
316
+ export const DeploymentDivergenceDeclarationSchema = z.object({
317
+ /** The modules this deployment does not ship. D-101, unchanged in substance. */
318
+ omittedModules: z.array(OmittedModuleSchema).default([]),
319
+ /**
320
+ * Wrapping order, per registration name, for a name more than one of this
321
+ * deployment's overlay modules decorates — innermost first.
322
+ *
323
+ * **Checked, never applied.** The composer emits modules in its own order and
324
+ * drains decorations once; this declares that the resulting order was the
325
+ * intended one, and a composition that disagrees refuses. Making the
326
+ * declaration authoritative would put a hand-written array in front of the
327
+ * composer's topological emission, which is two orderings of one thing waiting
328
+ * to disagree.
329
+ *
330
+ * Only a deployment's own overlay modules can appear here: a core module and
331
+ * an installed package may not decorate a name they do not own (D-156.4), so
332
+ * every ambiguity this can resolve is between two of them.
333
+ *
334
+ * Nothing reads it yet — the supply is P4 of
335
+ * `specs/107-override-report-and-ladder/`.
336
+ */
337
+ decorationOrder: z
338
+ .record(z.string().min(1), z.array(z.string().regex(moduleIdRe)).min(1))
339
+ .default({}),
340
+ /**
341
+ * One sentence per divergence the platform derives, keyed by the derived
342
+ * entry's own key — `<kind>:<module>:<subject>`, never a path and never a
343
+ * line.
344
+ *
345
+ * A flat map rather than a reason field on a per-kind array, and the
346
+ * difference is structural rather than stylistic: a map can only ever
347
+ * *answer*. So the declaration cannot add a divergence the derivation did not
348
+ * find, nor hide one it did — the population is the report's and the judgement
349
+ * is this.
350
+ *
351
+ * The key's grammar is checked where the population it keys into exists;
352
+ * nothing reads this yet — the report is P2 of
353
+ * `specs/107-override-report-and-ladder/`.
354
+ */
355
+ reasons: z.record(z.string().min(1), z.string().min(20).max(800)).default({}),
356
+ })
357
+ // Three fields are the whole vocabulary, so a fourth is a typo — and a
358
+ // mistyped field name under a lenient object is silently stripped, which
359
+ // reads as "this deployment declares nothing" for a file whose author wrote
360
+ // a declaration. Refusing it names the key.
361
+ .strict();
362
+ /**
363
+ * Refusal-token grammar for {@link ModuleErrorCodeDeclarationSchema}.
364
+ *
365
+ * One code, several reasons — `specs/082-error-code-ownership/contracts/error-code-ownership.md`
366
+ * §1.4. The envelope reads `details.code` and looks up `errors.<CODE>.<token>`,
367
+ * or `errors.<CODE>` when the raise carries no token.
368
+ *
369
+ * **It is a choice between two keys and not a fall-back**, which this note said
370
+ * it was until D-190 (`specs/080-f4-real-scope/rulings.md`) measured it:
371
+ * `localizeErrorEnvelope` composes one key, asks for it once and never re-asks.
372
+ * So a code every raise of which carries a token has no reader for its
373
+ * `errors.<CODE>` sentence — the operator never sees it, and deleting it is
374
+ * still wrong, because `check:error-translations` asks its P1 question at that
375
+ * key and at no other.
376
+ */
377
+ export const errorCodeTokenRe = /^[a-z][a-z0-9_]*$/;
378
+ /**
379
+ * One error code a module claims as its own
380
+ * (`specs/090-module-owned-error-codes/contracts/error-code-declaration.md` §1.1).
381
+ *
382
+ * **No `message` field, and that is a decision.** The English sentence a caller
383
+ * sees when nothing is translated is the one the raising code wrote: it already
384
+ * exists, it is written where the condition is known, and it can interpolate.
385
+ * A manifest message would be a third English sentence for one condition, and
386
+ * the two would drift exactly as a permission's `label` and its
387
+ * `adminRoles.permission.<code>` bundle key already do. The translated
388
+ * sentences live in the declaring module's own `i18n/<language>.json` under
389
+ * `errors.<CODE>`, which is where the envelope already looks.
390
+ *
391
+ * **`tokens` is declared rather than inferred** because a static reader that
392
+ * does not know the token set cannot tell `errors.CART_COUPON_REJECTED.expired`
393
+ * from a key whose tail is not a code at all — which is a finding. Fourteen keys
394
+ * in `invoices` and `carts` have this shape today.
395
+ */
396
+ export const ModuleErrorCodeDeclarationSchema = z.object({
397
+ code: z.string().regex(errorCodeRe),
398
+ tokens: z.array(z.string().regex(errorCodeTokenRe)).optional(),
399
+ });
400
+ /**
401
+ * One capability this module **owns** and declares mutually exclusive
402
+ * (`specs/132-connector-family-discovery/contracts/module-capabilities.md` R3.1).
403
+ *
404
+ * The owner declares exclusivity, never the member, and three things follow that
405
+ * are otherwise loose ends. The refusal code stays on the semantic owner, which
406
+ * is what D-95.2 requires — a member raises it and must **not** declare it. A
407
+ * member cannot make a capability exclusive by accident, and cannot un-make it.
408
+ * And if the owner module is not installed in a deployment, the capability is
409
+ * simply not exclusive there, which is the honest answer rather than a refusal:
410
+ * without the shared layer there is no lock row and nothing to enforce with
411
+ * (R3.5).
412
+ */
413
+ export const ExclusiveCapabilitySchema = z.object({
414
+ /** The capability this module owns and declares mutually exclusive. */
415
+ key: z.string().regex(capabilityKeyRe),
416
+ /** The code raised when a second member is activated. Owned by this module. */
417
+ errorCode: z.string().regex(errorCodeRe),
418
+ });
419
+ export const ModuleManifestSchema = z.object({
420
+ id: z.string().regex(moduleIdRe),
421
+ name: z.string().min(1).max(120),
422
+ description: z.string().max(2000).optional(),
423
+ version: z.string().regex(moduleVersionRe),
424
+ dependencies: z.array(z.string().regex(moduleIdRe)).default([]),
425
+ /**
426
+ * Real port edges withheld from `dependencies` for install-ordering reasons
427
+ * (feature 073, Amendment A1). Consumed by `check-port-dependencies.ts` and
428
+ * by the lifecycle's flip-time dependency refusals; never by the install
429
+ * order or the migration order.
430
+ */
431
+ acknowledgedDependencies: z.array(ModuleAcknowledgedDependencySchema).optional(),
432
+ /**
433
+ * Real container edges that deliberately do **not** bind the operator (D-44).
434
+ * Consumed by `check-port-dependencies.ts` for the ownership claim; read by
435
+ * no graph and by no ordering. See {@link ModuleNonBindingDependencySchema}.
436
+ */
437
+ nonBindingDependencies: z.array(ModuleNonBindingDependencySchema).optional(),
438
+ /**
439
+ * Operator-activation control (feature 073). Optional only while the
440
+ * conversion sweep is in flight: `check-module-gating` requires it as soon
441
+ * as a module's seams are converted, so a converted module without it fails
442
+ * CI rather than resolving to an implicit "on".
443
+ */
444
+ activation: ModuleActivationSchema.optional(),
445
+ /**
446
+ * Per-module settings declaration consumed by the existing feature 004
447
+ * `ManifestReconciler`. When present, its `moduleCode` MUST equal the
448
+ * outer `id` — the loader enforces this at boot.
449
+ */
450
+ settings: ModuleSettingsManifestSchema.optional(),
451
+ /**
452
+ * Per-module Admin UI translation declaration (feature 019).
453
+ * When present, the lifecycle install hook ingests bundle JSON files
454
+ * from `<bundlesDir>` into the platform's `translation_bundles` store.
455
+ */
456
+ i18n: ModuleI18nManifestSchema.optional(),
457
+ /**
458
+ * Per-module documentation declaration (feature 100 / roadmap F12).
459
+ *
460
+ * `{ dir }` — the module ships its pages at that directory under its own
461
+ * root; `false` — it ships none, deliberately; **absent** — nobody has
462
+ * decided, which is where every module stands in Phase 1 while the pages are
463
+ * still in the site's own tree. See {@link ModuleDocsDeclarationSchema} for
464
+ * why the last two are not one state.
465
+ */
466
+ docs: ModuleDocsDeclarationSchema.optional(),
467
+ /**
468
+ * Per-module demo data declaration (feature 113, D-209).
469
+ *
470
+ * `{ summary, seed, reset, after? }` — the module ships demo rows for its own
471
+ * tables; `false` — it has nothing to demonstrate, deliberately; **absent** —
472
+ * nobody has decided. See {@link ModuleDemoDeclarationSchema} for why the last
473
+ * two are not one state, and {@link ModuleDemoManifest} for what a body may
474
+ * do.
475
+ *
476
+ * Declaring it creates **no** lifecycle edge: it puts no module in
477
+ * `dependencies`, changes no migration order and does not stand in the way of
478
+ * an operator switching another module off (§2.3, FR-005).
479
+ */
480
+ demo: ModuleDemoDeclarationSchema.optional(),
481
+ /**
482
+ * Per-module Admin Command Palette action declarations (feature 020).
483
+ * Each entry becomes a row in `module_actions` at install time and is
484
+ * surfaced in the admin's command palette under the Actions group.
485
+ * Within-module id uniqueness is enforced by the schema.
486
+ */
487
+ actions: ModuleActionsManifestSchema.optional(),
488
+ /**
489
+ * Per-module admin permission codes merged into the assignable catalogue
490
+ * when the module is enabled (feature 026).
491
+ */
492
+ permissions: z.array(modulePermissionDeclarationSchema).optional(),
493
+ /**
494
+ * Per-module transactional email declarations (feature 047). Each entry is
495
+ * reconciled into `transactional_emails` at boot; default subject/content are
496
+ * supplied separately at runtime via the EmailDefaultsRegistry.
497
+ */
498
+ transactionalEmails: z.array(transactionalEmailManifestEntrySchema).optional(),
499
+ /**
500
+ * The capability families this module declares itself a **member** of
501
+ * (feature 132, `contracts/module-capabilities.md` R1).
502
+ *
503
+ * A key is a kebab-case string, not an enum: `CAPABILITY_KEYS` spells the
504
+ * three this repository mints, and a capability owned by a package this
505
+ * repository does not contain is spelled by its owner and needs no entry
506
+ * anywhere here. That openness is the point — it is what lets a connector
507
+ * installed from npm and a per-deployment overlay module join a family on the
508
+ * same terms as a core module, which the three arrays below cannot (R2.1).
509
+ *
510
+ * It carries **membership only** (R1.4). The activation setting code is
511
+ * `activation.settingCode`, which every member already declares and which the
512
+ * registry cache already indexes by module; the three arrays this field
513
+ * replaces each carried a second field byte-identical to it in every entry
514
+ * that could be checked, which is D-100 written into a schema.
515
+ *
516
+ * Declaring it creates **no** lifecycle edge (R1.5): no `dependencies` entry,
517
+ * no migration ordering, no install ordering, and no obstacle to an operator
518
+ * switching the capability's owner off. The same sentence `demo` carries, and
519
+ * enforced the same way — no graph reads this field.
520
+ *
521
+ * Absent means "this module declares no capability", which is true of most
522
+ * modules and is not a finding.
523
+ */
524
+ capabilities: z.array(z.string().regex(capabilityKeyRe)).optional(),
525
+ /**
526
+ * The capabilities this module **owns** and declares mutually exclusive
527
+ * (feature 132, R3.1). See {@link ExclusiveCapabilitySchema}.
528
+ */
529
+ exclusiveCapabilities: z.array(ExclusiveCapabilitySchema).optional(),
530
+ // Feature 132 — `pimConnector`, `invoiceLedger` and `erpConnector` are **gone**.
531
+ //
532
+ // Three `z.literal(true).optional()` flags, one per family, each declared by one or
533
+ // two modules and each read by **nothing**: every exclusion answered from a
534
+ // hand-written array in this package instead. The doc comment on `invoiceLedger`
535
+ // asked for this removal by name, and put the question it could not answer alone —
536
+ // *"is the flag the source, or the table?"* The answer is neither: the manifest is
537
+ // the source, in one field for all families (`capabilities` above), and both the
538
+ // flags and the tables go.
539
+ //
540
+ // This is a **breaking** manifest-schema change for any consumer that declared one,
541
+ // which is three modules in this tree and potentially a package outside it; the
542
+ // replacement is one line and is additive.
543
+ /**
544
+ * The operator-visible error codes this module owns (feature 090, D-182).
545
+ *
546
+ * The declaration is what routes the code's sentence to this module's bundle:
547
+ * `errors.<CODE>` in `<module>/i18n/<language>.json`. Which module owns a code
548
+ * is `specs/082-error-code-ownership/contracts/error-code-ownership.md` §1 —
549
+ * the domain noun decides, never the thrower, so `orders` raising `CART_EMPTY`
550
+ * leaves the code owned by `carts`.
551
+ *
552
+ * Absent means "this module owns no operator-visible error code", which is
553
+ * true of most modules and is not a finding.
554
+ */
555
+ errorCodes: z.array(ModuleErrorCodeDeclarationSchema).optional(),
556
+ /**
557
+ * The Page Builder blocks this module owns (feature 096, FR-001/FR-006).
558
+ *
559
+ * A block's `name` is `<this module's id>.<LocalName>` and is **persisted**:
560
+ * it is written into the `type` position of a Puck node in a `jsonb` column
561
+ * and is the only link between a stored node and the module that can render
562
+ * it. Which module owns a block is the domain noun its fields and data belong
563
+ * to — the rule `specs/082-error-code-ownership/contracts/error-code-ownership.md`
564
+ * §1 already applies to error codes — never the package the renderer file
565
+ * currently sits in.
566
+ *
567
+ * Absent means "this module owns no Page Builder block", which is true of
568
+ * most modules and is not a finding.
569
+ */
570
+ blocks: z.array(BlockDefinitionSchema).optional(),
571
+ /**
572
+ * The palette sections this module declares (feature 096, FR-009).
573
+ *
574
+ * Declared rather than hard-coded so that contributing a block into a section
575
+ * costs no edit to a shared `categories` map in a package the contributor
576
+ * does not own. Two modules declaring the same key for the same context is
577
+ * expected and merges; a category exists per context, so `layout` for `cms`
578
+ * and `layout` for `email` are two entries.
579
+ */
580
+ blockCategories: z.array(BlockCategorySchema).optional(),
581
+ /**
582
+ * The environment inputs this module owns (`specs/117-instance-bring-up/`
583
+ * FR-002; `contracts/environment-inputs.md` §R2.2).
584
+ *
585
+ * **This is the only way a module's requirements can reach a client.** A
586
+ * module package ships `dist`, `i18n` and `docs`; `.env.example` is a file in
587
+ * *this* repository. So a client who scaffolds an instance, installs thirty
588
+ * modules and copies the example gets a file that does not mention the
589
+ * variables those modules read — and meets each one as a boot that failed for
590
+ * a reason nothing named. Declared here, the same tree walk that picks up
591
+ * `permissions`, `actions` and `errorCodes` carries them into the generated
592
+ * manifest index, so a core module, a per-deployment overlay module and an
593
+ * installed package all declare on identical terms.
594
+ *
595
+ * **A module declares only what it *owns*.** Most of what a module reads is
596
+ * not its own: `NODE_ENV`, `BACKEND_ROLE`, `STOREFRONT_BASE_URL`,
597
+ * `REVALIDATE_SECRET` and `SETTINGS_SECRET_ENCRYPTION_KEY` are the platform's,
598
+ * declared once in `packages/platform/src/env/index.ts`, and a module's read
599
+ * of one is satisfied by that declaration. Declaring them again would be one
600
+ * fact with thirty homes and thirty `describes` (D-100), so
601
+ * {@link defineModuleManifest} refuses an entry whose `owner` is not this
602
+ * module — and `check:env-inputs` refuses one whose name the platform already
603
+ * owns.
604
+ *
605
+ * Absent means "this module reads no environment variable of its own", which
606
+ * is true of most modules and is not a finding.
607
+ */
608
+ env: z.array(EnvironmentInputSchema).optional(),
609
+ });
610
+ /**
611
+ * The three cross-field activation rules (feature 073,
612
+ * `contracts/module-activation-manifest.md`). They live here rather than in
613
+ * the schema because a Zod union of two non-strict objects accepts a value
614
+ * carrying both forms, and because the resulting message has to name the
615
+ * module the author is looking at.
616
+ */
617
+ function assertActivationRules(id, activation) {
618
+ const block = activation;
619
+ const declaresControl = typeof block['settingCode'] === 'string' && typeof block['default'] === 'boolean';
620
+ const declaresNonDeactivatable = block['nonDeactivatable'] === true &&
621
+ typeof block['reason'] === 'string' &&
622
+ block['reason'].length > 0;
623
+ // 1. Exactly one form.
624
+ if (declaresControl === declaresNonDeactivatable) {
625
+ throw new Error(`[contracts/modules] manifest "${id}" must declare exactly one activation ` +
626
+ `form: either { settingCode, default } or { nonDeactivatable: true, reason }.`);
627
+ }
628
+ // 2. An `_`-prefixed id is platform-internal by convention (`moduleIdRe`);
629
+ // this makes the convention enforceable.
630
+ if (id.startsWith('_') && !declaresNonDeactivatable) {
631
+ throw new Error(`[contracts/modules] manifest "${id}" is platform-internal (leading "_") ` +
632
+ `and MUST declare activation as { nonDeactivatable: true, reason }.`);
633
+ }
634
+ // 3. The control belongs to the declaring module. Adopting an existing
635
+ // ad-hoc control (FR-014) is allowed precisely because every such code
636
+ // — `blog.enabled`, `prompt_actions.enabled`, `ksef.integration.enabled` —
637
+ // already sits under its own module's namespace.
638
+ if (declaresControl) {
639
+ const code = block['settingCode'];
640
+ if (code !== id && !code.startsWith(`${id}.`)) {
641
+ throw new Error(`[contracts/modules] manifest "${id}" declares activation setting ` +
642
+ `"${code}", which is outside the module's own namespace ` +
643
+ `("${id}" or "${id}.*").`);
644
+ }
645
+ }
646
+ }
647
+ /**
648
+ * The four cross-field capability rules (feature 132,
649
+ * `contracts/module-capabilities.md` R4).
650
+ *
651
+ * They sit beside the activation rules for the reason `assertActivationRules`
652
+ * states about itself: they are cross-field, a non-strict object schema accepts
653
+ * a value carrying both arms, and the message has to name the module the author
654
+ * is looking at — and the key, since a manifest may declare several.
655
+ *
656
+ * **Two rules are deliberately not here**, and both for the same reason:
657
+ * this function sees **one** manifest and cannot see a family. "Exactly one
658
+ * installed module may own a key" (R3.4) and "a member of an *exclusive* key may
659
+ * not declare `activation.default: true`" (R3.6) are refused at derivation, in
660
+ * `capabilityRegistryFrom`, where both facts are in hand.
661
+ */
662
+ function assertCapabilityRules(m) {
663
+ const memberships = m.capabilities ?? [];
664
+ const owned = m.exclusiveCapabilities ?? [];
665
+ // R4.1 — a member with no activation control cannot participate in an
666
+ // exclusion that is resolved on the activation axis, so the declaration would
667
+ // be a claim nothing could ever check. Asked of members only: an owner is not
668
+ // resolved on that axis, its members are.
669
+ if (memberships.length > 0 && m.activation === undefined) {
670
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares capability membership ` +
671
+ `(${memberships.join(', ')}) but no \`activation\` block. A member with no ` +
672
+ `activation control cannot participate in an exclusion that is resolved on ` +
673
+ `the activation axis, so the membership could never be enforced or released.`);
674
+ }
675
+ // R4.2 — a duplicate says nothing the single entry does not, and a family read
676
+ // that counts entries rather than modules would count this member twice.
677
+ const seenMembership = new Set();
678
+ for (const key of memberships) {
679
+ if (seenMembership.has(key)) {
680
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares capability "${key}" twice ` +
681
+ `in \`capabilities\`. Membership is a set; drop the duplicate.`);
682
+ }
683
+ seenMembership.add(key);
684
+ }
685
+ // R4.3 — two entries for one key are two refusal codes for one condition, and
686
+ // nothing decides which of them an operator meets.
687
+ const seenOwned = new Set();
688
+ for (const entry of owned) {
689
+ if (seenOwned.has(entry.key)) {
690
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares capability "${entry.key}" ` +
691
+ `twice in \`exclusiveCapabilities\`. A key has one owner and one refusal ` +
692
+ `code; two entries leave nothing to decide which an operator meets.`);
693
+ }
694
+ seenOwned.add(entry.key);
695
+ }
696
+ // R4.4 / R3.3 — an owner that is also a member would exclude itself from its
697
+ // own family. Two *different* keys in the two arrays are fine and expected:
698
+ // keys never exclude each other (`capability-exclusivity.md` R2.5).
699
+ for (const key of seenOwned) {
700
+ if (seenMembership.has(key)) {
701
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares capability "${key}" as both ` +
702
+ `a membership and an exclusive capability it owns. An owner that is also a ` +
703
+ `member would exclude itself from its own family.`);
704
+ }
705
+ }
706
+ }
707
+ /**
708
+ * The three `nonBindingDependencies` rules (D-44 §5).
709
+ *
710
+ * They sit beside the activation rules for the same reason: two of the three
711
+ * are cross-field — one reads `dependencies` and `acknowledgedDependencies`,
712
+ * one reads `kind` against `whenAbsent` — and the message has to name the
713
+ * module the author is looking at.
714
+ */
715
+ function assertNonBindingRules(m) {
716
+ const acknowledged = new Set((m.acknowledgedDependencies ?? []).map((edge) => edge.moduleId));
717
+ for (const edge of m.nonBindingDependencies ?? []) {
718
+ // 1. No self-edges. The array's element regex applies per element and
719
+ // cannot see the outer id, exactly as with `dependencies`.
720
+ if (edge.moduleId === m.id) {
721
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares a non-binding dependency on ` +
722
+ `itself (forbidden).`);
723
+ }
724
+ // 2. One edge, one claim, in one place — the mirror of the
725
+ // `acknowledgedDependencies` rule above. A target declared in either of
726
+ // the other two arrays already carries the bind, so a withdrawal beside
727
+ // it is a second record of the same edge that nothing keeps in step.
728
+ if (m.dependencies.includes(edge.moduleId)) {
729
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares a non-binding dependency on ` +
730
+ `"${edge.moduleId}", which it already declares in \`dependencies\` — that ` +
731
+ `declaration already binds the operator, so drop one of the two.`);
732
+ }
733
+ if (acknowledged.has(edge.moduleId)) {
734
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares a non-binding dependency on ` +
735
+ `"${edge.moduleId}", which it already acknowledges — an acknowledged edge is ` +
736
+ `read by the refusal graph, so the two claims contradict each other.`);
737
+ }
738
+ // 3. `whenAbsent` is the `degrades-without` kind's entire content: the
739
+ // behaviour the module promises and the sentence the platform screen
740
+ // renders. A `contributes-to` edge has no degradation to describe.
741
+ if (edge.kind === 'degrades-without' && edge.whenAbsent === undefined) {
742
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares "${edge.moduleId}:${edge.name}" ` +
743
+ `as \`degrades-without\` with no \`whenAbsent\` — the kind is a promise about ` +
744
+ `behaviour and the sentence is what a reviewer and an off-state test hold it to.`);
745
+ }
746
+ // 3a. And it is the whole of `refuses-without`, for a sharper reason: the
747
+ // outcome that kind produces is the one an undeclared gated port
748
+ // produces anyway, so the sentence is the only thing the declaration
749
+ // adds. Without it the entry classifies identically to no entry at
750
+ // all, and the operator's dialog falls back to a translated default
751
+ // that names no capability.
752
+ if (edge.kind === 'refuses-without' && edge.whenAbsent === undefined) {
753
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares "${edge.moduleId}:${edge.name}" ` +
754
+ `as \`refuses-without\` with no \`whenAbsent\` — the ledger classifies such an ` +
755
+ `edge exactly as it classifies an undeclared one, so the sentence is the whole ` +
756
+ `of what the declaration buys. Name what refuses, in the operator's words.`);
757
+ }
758
+ if (edge.kind === 'contributes-to' && edge.whenAbsent !== undefined) {
759
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares "${edge.moduleId}:${edge.name}" ` +
760
+ `as \`contributes-to\` with a \`whenAbsent\` — a push into an ungated registry ` +
761
+ `degrades nothing, so either drop the sentence or the edge is a pull and the ` +
762
+ `kind is \`degrades-without\`.`);
763
+ }
764
+ }
765
+ }
766
+ /**
767
+ * The `errorCodes` refusals (feature 090,
768
+ * `contracts/error-code-declaration.md` §2, first layer).
769
+ *
770
+ * They live here rather than in the schema for the reason the activation rules
771
+ * do: two of them are cross-element — a duplicate is a relationship between two
772
+ * entries, which an element schema cannot see — and every message has to name
773
+ * the module the author is looking at. All four fire on import, on the author's
774
+ * machine, with no instance and no database.
775
+ *
776
+ * What this layer deliberately does **not** refuse is a code **another** module
777
+ * declares. It sees one manifest and cannot see a second, so a partial refusal
778
+ * here called "the collision rule" would be a green that means "not looking".
779
+ * The collision rule is composition's (§3), and an in-repository collision is
780
+ * refused before that, in CI.
781
+ *
782
+ * Nor does it refuse a code that is a member of `ERROR_CODES`. After feature
783
+ * 090's migration every core module's declarations are members of it, so such a
784
+ * rule would refuse the platform's own manifests; there is no origin field to
785
+ * condition it on, and adding one would be a self-certified exemption issued by
786
+ * the measured party.
787
+ */
788
+ function assertErrorCodeRules(m) {
789
+ const seenCodes = new Set();
790
+ for (const declaration of m.errorCodes ?? []) {
791
+ if (!errorCodeRe.test(declaration.code)) {
792
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares error code ` +
793
+ `"${declaration.code}", which is not SCREAMING_SNAKE_CASE ` +
794
+ `(${String(errorCodeRe)}) — the code travels verbatim on the wire and ` +
795
+ 'is the tail of the `errors.<CODE>` key its sentence is written under.');
796
+ }
797
+ if (seenCodes.has(declaration.code)) {
798
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares error code ` +
799
+ `"${declaration.code}" twice — one code has one owner and one sentence, ` +
800
+ 'so the second entry can only disagree with the first.');
801
+ }
802
+ seenCodes.add(declaration.code);
803
+ const seenTokens = new Set();
804
+ for (const token of declaration.tokens ?? []) {
805
+ if (!errorCodeTokenRe.test(token)) {
806
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares refusal token "${token}" ` +
807
+ `under "${declaration.code}", which does not match ${String(errorCodeTokenRe)} — ` +
808
+ 'the token is the tail of `errors.<CODE>.<token>` and a key that does not ' +
809
+ 'parse is a key nothing reads.');
810
+ }
811
+ if (seenTokens.has(token)) {
812
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares refusal token "${token}" ` +
813
+ `twice under "${declaration.code}" — one token is one sentence.`);
814
+ }
815
+ seenTokens.add(token);
816
+ }
817
+ }
818
+ }
819
+ /**
820
+ * The four block-declaration rules (feature 096,
821
+ * `specs/096-page-builder-block-ownership/contracts/block-definition.md` §1).
822
+ *
823
+ * They live here rather than in the schema for the reason the activation rules
824
+ * do: each is cross-field — one reads a block's `name` against the outer `id`,
825
+ * one reads its `category` against the manifest's own `blockCategories`, one
826
+ * reads those declarations against each other — and every message has to name
827
+ * the module the author is looking at. They fire on
828
+ * import, on the author's machine, with no instance and no database, which
829
+ * matters more here than anywhere else in this file: a block name is written
830
+ * into `jsonb` and never rewritten, so a wrong one caught in CI has already
831
+ * been typed into a manifest, and one caught after a release is permanent.
832
+ *
833
+ * `check:block-names` re-derives rules 1–3 for a manifest built without this
834
+ * helper — the same belt-and-braces `check-port-dependencies.ts` applies to
835
+ * `nonBindingDependencies`.
836
+ *
837
+ * **What this layer cannot decide is anything about a second manifest**, and
838
+ * the limit is the one `assertErrorCodeRules` states for itself. Two modules
839
+ * declaring one block name is composition's question and the check's; two
840
+ * modules declaring one `(key, context)` category is neither, because it is
841
+ * **normal and merges** — `contracts/block-definition.md` §1.1 is the ruling,
842
+ * the total order the merge resolves by and the two CI signals that hold
843
+ * in-tree modules to agreeing. Nothing here restates it.
844
+ *
845
+ * Rule 2 is therefore enforced **within the declaring manifest**, and per
846
+ * **context**: a block's category must be declared beside it, for every one of
847
+ * the block's `contexts`. That is what FR-009 asks for — a contributor declares
848
+ * the section in its own manifest instead of editing a shared map — and the
849
+ * per-context reading is T107's correction to Phase 1, which shipped "at least
850
+ * one". Under the weaker reading a block declared for `cms` and `email` whose
851
+ * section exists only in `cms` is uninsertable in the e-mail palette with no
852
+ * error anywhere, which is FR-009's silent-loss shape one level down.
853
+ */
854
+ function assertBlockRules(m) {
855
+ // 4. One author, one section, one record. Judged first, and before any block
856
+ // is read: a block is judged *against* `blockCategories`, so measuring it
857
+ // against a set that contradicts itself reports the wrong defect. Unlike a
858
+ // cross-module duplicate — which is normal and merges (§1.1) — this one
859
+ // has a single author and is decidable where it is written.
860
+ const declaredSections = new Set();
861
+ for (const category of m.blockCategories ?? []) {
862
+ for (const context of category.contexts) {
863
+ const pair = `${category.key}\u0000${context}`;
864
+ if (declaredSections.has(pair)) {
865
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares the palette section ` +
866
+ `"${category.key}" twice for context "${context}" — a section is one record, ` +
867
+ 'resolved as one record, so a manifest that states it twice has stated a ' +
868
+ 'title, a weight and a visibility for nobody to reconcile.');
869
+ }
870
+ declaredSections.add(pair);
871
+ }
872
+ }
873
+ for (const block of m.blocks ?? []) {
874
+ // 0. The grammar, before anything reads a segment of it. A name with no
875
+ // separator has no owner segment to compare against `id`, so a message
876
+ // about ownership would be a message about the wrong thing. The schema
877
+ // refuses it too (`BlockDefinitionSchema`), and parses last; this is the
878
+ // copy that names the module, exactly as `assertErrorCodeRules` re-tests
879
+ // `errorCodeRe`.
880
+ if (!blockNameRe.test(block.name)) {
881
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares block "${block.name}", which is ` +
882
+ `not a namespaced block name (${String(blockNameRe)}) — the name is persisted ` +
883
+ 'into `jsonb` and its owner segment is the only link between a stored node and ' +
884
+ 'the module that can render it.');
885
+ }
886
+ // 1. One block, one owner, stated once. The owner is the name's first
887
+ // segment and there is no `ownerModule` field to disagree with it.
888
+ const owner = block.name.slice(0, block.name.indexOf('.'));
889
+ if (owner !== m.id) {
890
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares block "${block.name}", whose ` +
891
+ `owner segment "${owner}" is not this module's id — a block has exactly one ` +
892
+ 'owner and the name is where that owner is stated, so declaring it here would ' +
893
+ `make "${owner}" unable to own its own block.`);
894
+ }
895
+ // 2. A block offered on no surface. Refused before the category, which
896
+ // cannot be judged without the contexts to judge it against.
897
+ if (block.contexts.length === 0) {
898
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares block "${block.name}" with an ` +
899
+ 'empty `contexts` — a block offered on no surface appears in no palette, which ' +
900
+ 'is a declaration with no reader.');
901
+ }
902
+ // 3. The category is a declared key, not free text, and it is declared for
903
+ // **every** one of the block's contexts (T107). The block's own order is
904
+ // what the message names, so an author fixing two missing contexts is
905
+ // sent to the first of them rather than to whichever the set iterated.
906
+ const missingContext = block.contexts.find((context) => !(m.blockCategories ?? []).some((category) => category.key === block.category && category.contexts.includes(context)));
907
+ if (missingContext !== undefined) {
908
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares block "${block.name}" in ` +
909
+ `category "${block.category}", which this manifest does not declare in ` +
910
+ `\`blockCategories\` for context "${missingContext}" — a block's section is ` +
911
+ 'declared beside it for every context the block is offered in, or the block ' +
912
+ 'is uninsertable in that palette with no error anywhere.');
913
+ }
914
+ }
915
+ }
916
+ /**
917
+ * The two `demo.after` refusals (feature 113,
918
+ * `contracts/module-demo-data-layer.md` §4.3–§4.4).
919
+ *
920
+ * They sit beside the rules above for the reason those give: both are
921
+ * cross-field — one reads an entry against the outer `id`, one reads the
922
+ * entries against each other — and the message has to name the module the
923
+ * author is looking at. The element regex applies per element and can see
924
+ * neither.
925
+ *
926
+ * What this layer deliberately does **not** refuse is an `after` naming a
927
+ * module that is not installed. §4.4 rules that such an entry orders nothing
928
+ * and is not a finding, and this layer sees one manifest, so a rule keyed on
929
+ * "is that module here" would be a green that means "not looking" in a client's
930
+ * instance and a false red in a partial one.
931
+ */
932
+ function assertDemoRules(m) {
933
+ if (m.demo === undefined || m.demo === false)
934
+ return;
935
+ const seen = new Set();
936
+ for (const after of m.demo.after ?? []) {
937
+ if (after === m.id) {
938
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares \`demo.after\` on itself ` +
939
+ `(forbidden) — the list orders this module's demo against *other* modules', ` +
940
+ 'and a self-entry orders nothing.');
941
+ }
942
+ if (seen.has(after)) {
943
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares \`demo.after\` "${after}" ` +
944
+ 'twice — one ordering preference is stated once, so the second entry can ' +
945
+ 'only agree with the first.');
946
+ }
947
+ seen.add(after);
948
+ }
949
+ }
950
+ /**
951
+ * A module declares only the environment inputs it **owns**
952
+ * (`specs/117-instance-bring-up/` FR-002, `contracts/environment-inputs.md`
953
+ * §R2.2).
954
+ *
955
+ * The rule this refuses is the one an author gets wrong by being helpful. Of
956
+ * the 28 variables the module tree reads, 7 are the platform's — `NODE_ENV`,
957
+ * `BACKEND_ROLE`, `STOREFRONT_BASE_URL`, `PUBLIC_API_BASE_URL`,
958
+ * `BACKEND_PUBLIC_URL`, `REVALIDATE_SECRET` and
959
+ * `SETTINGS_SECRET_ENCRYPTION_KEY` — read by thirty modules between them, and an
960
+ * author declaring what their module reads rather than what it owns writes the
961
+ * same fact into thirty manifests with thirty `describes`. Whichever a reader
962
+ * reaches first wins, and the other twenty-nine drift.
963
+ *
964
+ * It is refused **here**, in one manifest, at import time, because that is
965
+ * everything this layer can decide on its own: an `owner` naming another module
966
+ * or the platform is wrong whatever the rest of the estate holds. What it
967
+ * cannot decide — whether the name is one the *platform* already declares — is
968
+ * `check:env-inputs`' `module-declares-a-platform-input`, which needs the
969
+ * platform's declaration to answer.
970
+ */
971
+ function assertEnvironmentInputRules(m) {
972
+ for (const input of m.env ?? []) {
973
+ const owner = input.owner;
974
+ if (owner.kind === 'module' && owner.moduleId === m.id)
975
+ continue;
976
+ const declared = owner.kind === 'module'
977
+ ? `the module "${owner.moduleId}"`
978
+ : owner.kind === 'application'
979
+ ? `the ${owner.application} application`
980
+ : 'the platform';
981
+ throw new Error(`[contracts/modules] manifest "${m.id}" declares the environment input ` +
982
+ `"${input.name}" as owned by ${declared} — a module declares only what it owns. ` +
983
+ `A read of somebody else's input is satisfied by *their* declaration; declaring ` +
984
+ `it here would be one fact with two homes and two descriptions.`);
985
+ }
986
+ }
987
+ /**
988
+ * Identity-with-validation helper for module authors. Modules export a
989
+ * single `manifest` constant via this helper so TypeScript inference is
990
+ * preserved and the loader can ingest the validated payload directly.
991
+ */
992
+ export function defineModuleManifest(m) {
993
+ // Reject self-dependencies up front — Zod's array regex doesn't catch
994
+ // this because the id field's regex applies independently per element.
995
+ if (m.dependencies.includes(m.id)) {
996
+ throw new Error(`[contracts/modules] manifest "${m.id}" depends on itself (forbidden).`);
997
+ }
998
+ // Settings manifest's moduleCode must equal the outer id.
999
+ if (m.settings && m.settings.moduleCode !== m.id) {
1000
+ throw new Error(`[contracts/modules] manifest "${m.id}" carries a settings ` +
1001
+ `manifest with moduleCode "${m.settings.moduleCode}" (must match).`);
1002
+ }
1003
+ if (m.activation !== undefined) {
1004
+ assertActivationRules(m.id, m.activation);
1005
+ }
1006
+ for (const edge of m.acknowledgedDependencies ?? []) {
1007
+ if (edge.moduleId === m.id) {
1008
+ throw new Error(`[contracts/modules] manifest "${m.id}" acknowledges a dependency on ` +
1009
+ `itself (forbidden).`);
1010
+ }
1011
+ // An acknowledged edge is a declaration that the ordinary one is
1012
+ // impossible. Where both are present the ordinary one already carries the
1013
+ // install order *and* the refusal, and the acknowledgement is a second
1014
+ // record of the same edge that nothing keeps in step.
1015
+ if (m.dependencies.includes(edge.moduleId)) {
1016
+ throw new Error(`[contracts/modules] manifest "${m.id}" acknowledges "${edge.moduleId}", ` +
1017
+ `which it already declares in \`dependencies\` — the acknowledgement is ` +
1018
+ `for edges that cannot be declared, so drop one of the two.`);
1019
+ }
1020
+ }
1021
+ assertNonBindingRules(m);
1022
+ assertCapabilityRules(m);
1023
+ assertErrorCodeRules(m);
1024
+ assertBlockRules(m);
1025
+ assertDemoRules(m);
1026
+ assertEnvironmentInputRules(m);
1027
+ return ModuleManifestSchema.parse(m);
1028
+ }
1029
+ // ---------------------------------------------------------------------------
1030
+ // Module CLI commands (feature 080, T042b / D-160.9)
1031
+ // ---------------------------------------------------------------------------
1032
+ /**
1033
+ * The shape a command's name has to take: lowercase, hyphen-separated.
1034
+ *
1035
+ * A command is addressed as `<module id> <command name>` on the host's argv, so
1036
+ * the name shares the module id's alphabet minus the underscore — an operator
1037
+ * types `carts abandonment-sweep`, and `check:naming`'s route-segment rule is
1038
+ * the same shape for the same reason. The host validates against this rather
1039
+ * than accepting whatever a package declared: a name with a space in it is
1040
+ * unaddressable, and a name that differs from the one printed by `--list` is
1041
+ * worse than one that is refused.
1042
+ */
1043
+ export const MODULE_CLI_COMMAND_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
1044
+ // ---------------------------------------------------------------------------
1045
+ // Recent-activity eligibility (feature 080, T042j / D-163.1)
1046
+ // ---------------------------------------------------------------------------
1047
+ /**
1048
+ * The shape an audit action token has: `<object>.<verb>`, both snake_case.
1049
+ *
1050
+ * `product.create`, `stock_level.bulk_import`, `prompt_action.execute`. It is
1051
+ * the value stored in `audit_log_entries.action`, and it is matched here rather
1052
+ * than accepted as any string because the declaration is the *only* thing that
1053
+ * puts a token into the dashboard query's `$in` — a typo used to be caught by a
1054
+ * reviewer reading a hand-written array, and there is no array to read now.
1055
+ *
1056
+ * naming:allow-snake-case — the token is persisted verbatim in
1057
+ * `audit_log_entries.action` and is written by `Command.action`, so this is the
1058
+ * existing wire value rather than a new API field.
1059
+ */
1060
+ export const auditActionRe = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
1061
+ /**
1062
+ * One audit action a module offers to the admin home dashboard's
1063
+ * recent-activity card.
1064
+ *
1065
+ * `labelKey` is **relative to the declaring module's i18n namespace**, exactly
1066
+ * as a command-palette action's `labelKey` is: the module ships
1067
+ * `activity.verb.product.create` in its own `i18n/en.json` and `pl.json`, the
1068
+ * card resolves it as `t('<moduleId>', '<labelKey>')`. That is what lets a
1069
+ * third-party package render a verb in the operator's language without the host
1070
+ * shipping a string for it.
1071
+ *
1072
+ * `icon` comes from {@link KnownIconNameSchema}, so the admin maps it through
1073
+ * the one `icon-map.ts` it already has and a package cannot name a component
1074
+ * the SPA does not bundle.
1075
+ */
1076
+ export const RecentActivityEntrySchema = z.object({
1077
+ /** The `audit_log_entries.action` token, e.g. `product.create`. */
1078
+ action: z.string().regex(auditActionRe),
1079
+ icon: KnownIconNameSchema,
1080
+ /** Module-namespace-relative i18n key for the verb, e.g. `activity.verb.product.create`. */
1081
+ labelKey: z.string().min(1).max(255),
1082
+ });
1083
+ /**
1084
+ * A module's declaration that its activity is **eligible** for the dashboard's
1085
+ * recent-activity card — D-163.1, the first of the ruling's two axes.
1086
+ *
1087
+ * It is a declaration and not a decision. Whether a declared module's rows
1088
+ * actually appear is the operator's, held in
1089
+ * {@link recentActivityVisibilitySettingCode}'s Setting and defaulting to
1090
+ * visible — Constitution XVII's two-axis shape applied to a narrower object.
1091
+ * Neither axis overwrites the other: a module author cannot put entries on
1092
+ * somebody's home screen by fiat, and an operator cannot be surprised by a card
1093
+ * they did not configure.
1094
+ *
1095
+ * It replaces four hand-maintained tables that had already drifted apart inside
1096
+ * core (D-163): the server allow-list that filtered the dashboard query, the
1097
+ * action → module prefix map, the route's `module` enum and the admin's
1098
+ * `ACTIVITY_RENDERING`. Every one of them is derived from this now, so a
1099
+ * package's row reaches the card and a fifth hand-written entry has nowhere to
1100
+ * be written.
1101
+ *
1102
+ * Declared as an export of `manifest.ts` beside `installHook`,
1103
+ * `lifecycleParticipant` and `cliCommands`, walked by the same generator, so
1104
+ * core, overlay and an installed package declare one on identical terms.
1105
+ */
1106
+ export const ModuleRecentActivitySchema = z.object({
1107
+ entries: z.array(RecentActivityEntrySchema).min(1),
1108
+ });
1109
+ /**
1110
+ * Identity-with-validation helper for module authors, the twin of
1111
+ * {@link defineModuleManifest}.
1112
+ */
1113
+ export function defineModuleRecentActivity(declaration) {
1114
+ return ModuleRecentActivitySchema.parse(declaration);
1115
+ }
1116
+ /** The suffix every recent-activity visibility Setting code ends in. */
1117
+ export const RECENT_ACTIVITY_VISIBILITY_SETTING_SUFFIX = 'recent_activity_visible';
1118
+ /**
1119
+ * Raised when a module's id cannot carry a Setting code — see
1120
+ * {@link recentActivityVisibilitySettingCode}.
1121
+ */
1122
+ export class RecentActivitySettingCodeInvalid extends Error {
1123
+ name = 'RecentActivitySettingCodeInvalid';
1124
+ }
1125
+ /**
1126
+ * The Setting that holds the operator's choice for one declaring module.
1127
+ *
1128
+ * **Derived, never declared.** `activation.settingCode` is declared because a
1129
+ * module that already shipped an ad-hoc control had to be able to adopt it;
1130
+ * there is no such history here, and a declared code would be a fifth place a
1131
+ * module could disagree with the platform about its own name. D-163.1 also
1132
+ * fixes the default — visible — so there is nothing else for a declaration to
1133
+ * carry.
1134
+ */
1135
+ export function recentActivityVisibilitySettingCode(moduleId) {
1136
+ const code = `${moduleId}.${RECENT_ACTIVITY_VISIBILITY_SETTING_SUFFIX}`;
1137
+ if (!settingCodeRe.test(code)) {
1138
+ throw new RecentActivitySettingCodeInvalid(`[contracts/modules] module "${moduleId}" declares recent-activity eligibility, but ` +
1139
+ `"${code}" is not a valid setting code. A platform-internal module id (leading ` +
1140
+ `underscore) cannot own one; the card is for a domain module's activity.`);
1141
+ }
1142
+ return code;
1143
+ }
1144
+ /**
1145
+ * The settings manifest the platform reconciles for a module, which is the
1146
+ * module's own declaration plus the one Setting its recent-activity eligibility
1147
+ * implies.
1148
+ *
1149
+ * Two callers and one derivation, deliberately (D-100): the boot reconcile
1150
+ * walks every shipped module's settings, and the lifecycle orchestrator's
1151
+ * `install` reconciles exactly the arriving module's. A package has only the
1152
+ * second — since D-157.6(b) `install` is its sole author — so a second copy of
1153
+ * this merge would mean a packaged module's control existing on one path and
1154
+ * not the other.
1155
+ *
1156
+ * Returns `undefined` when the module declares neither, so a caller can keep
1157
+ * treating "no settings" as an absent value.
1158
+ */
1159
+ export function settingsManifestWithRecentActivity(manifest, recentActivity) {
1160
+ if (!recentActivity)
1161
+ return manifest.settings;
1162
+ const entry = {
1163
+ code: recentActivityVisibilitySettingCode(manifest.id),
1164
+ name: `${manifest.name}: show activity on the dashboard`,
1165
+ description: `Whether ${manifest.name}'s entries appear on the admin home dashboard's Recent ` +
1166
+ 'Activity card. Switching it off hides them from that card only — the audit trail ' +
1167
+ 'itself is unchanged and the entries stay on the audit-log screen.',
1168
+ valueType: 'boolean',
1169
+ defaultValue: true,
1170
+ // Managed on /platform/modules beside the module's activation control, the
1171
+ // surface an operator already uses for exactly this kind of choice. A
1172
+ // second control on the generic Settings screen would be two doors onto one
1173
+ // decision.
1174
+ hidden: true,
1175
+ ...(manifest.settings?.groups[0]?.code
1176
+ ? { groupCode: manifest.settings.groups[0].code }
1177
+ : {}),
1178
+ };
1179
+ if (!manifest.settings) {
1180
+ return {
1181
+ moduleCode: manifest.id,
1182
+ groups: [{ code: manifest.id, name: manifest.name }],
1183
+ settings: [{ ...entry, groupCode: manifest.id }],
1184
+ };
1185
+ }
1186
+ if (manifest.settings.settings.some((s) => s.code === entry.code)) {
1187
+ return manifest.settings;
1188
+ }
1189
+ return {
1190
+ ...manifest.settings,
1191
+ settings: [...manifest.settings.settings, entry],
1192
+ };
1193
+ }
1194
+ // ---------------------------------------------------------------------------
1195
+ // Registry record
1196
+ // ---------------------------------------------------------------------------
1197
+ export const RegistryStateSchema = z.enum([
1198
+ 'installing',
1199
+ 'installed',
1200
+ 'disabled',
1201
+ 'uninstalled',
1202
+ ]);
1203
+ /** Persisted shape of a row in `module_registrations` (admin HTTP DTO). */
1204
+ export const ModuleRegistryRecordSchema = z.object({
1205
+ moduleId: z.string().regex(moduleIdRe),
1206
+ state: RegistryStateSchema,
1207
+ version: z.string(),
1208
+ installedAt: z.iso.datetime(),
1209
+ lastStateChangeAt: z.iso.datetime(),
1210
+ lastInstallFailedAt: z.iso.datetime().nullable(),
1211
+ lastInstallError: z.string().nullable(),
1212
+ });
1213
+ // ---------------------------------------------------------------------------
1214
+ // Admin HTTP — `GET /api/v1/admin/modules` response shape
1215
+ // ---------------------------------------------------------------------------
1216
+ export const ModuleListItemFlagSchema = z.enum([
1217
+ 'orphan',
1218
+ 'pending-upgrade',
1219
+ 'dep-missing',
1220
+ 'dep-disabled',
1221
+ ]);
1222
+ export const ModuleListItemStateSchema = z.enum([
1223
+ 'installing',
1224
+ 'installed',
1225
+ 'disabled',
1226
+ 'uninstalled',
1227
+ 'not-installed',
1228
+ ]);
1229
+ export const ModuleListItemSchema = z.object({
1230
+ id: z.string(),
1231
+ name: z.string(),
1232
+ description: z.string().nullable(),
1233
+ version: z.object({
1234
+ registered: z.string().nullable(),
1235
+ onDisk: z.string().nullable(),
1236
+ }),
1237
+ state: ModuleListItemStateSchema,
1238
+ dependencies: z.array(z.string()),
1239
+ flags: z.array(ModuleListItemFlagSchema),
1240
+ installedAt: z.iso.datetime().nullable(),
1241
+ lastStateChangeAt: z.iso.datetime().nullable(),
1242
+ });
1243
+ export const ModuleListResponseSchema = z.object({
1244
+ modules: z.array(ModuleListItemSchema),
1245
+ });
1246
+ export const ModuleListQuerySchema = z.object({
1247
+ state: z
1248
+ .enum(['installing', 'installed', 'disabled', 'uninstalled'])
1249
+ .optional(),
1250
+ flag: z.enum(['orphan', 'pending-upgrade']).optional(),
1251
+ });
1252
+ // ---------------------------------------------------------------------------
1253
+ // Feature 073 — module presence projections
1254
+ // ---------------------------------------------------------------------------
1255
+ /**
1256
+ * One module's presence as the server computed it. `present` is the
1257
+ * conjunction of the two axes, precomputed server-side: neither frontend
1258
+ * recombines them, which is what makes "off means absent" one decision rather
1259
+ * than two implementations that can disagree.
1260
+ *
1261
+ * The axes stay separately visible because Constitution XVII requires the
1262
+ * Admin UI to render them differently — *installed but switched off* shows an
1263
+ * actionable control, *not available at platform level* shows absent or
1264
+ * blocked-with-a-reason.
1265
+ */
1266
+ export const ModulePresenceSchema = z.object({
1267
+ id: z.string().regex(moduleIdRe),
1268
+ /** platformAvailable && operatorActivated. */
1269
+ present: z.boolean(),
1270
+ platformState: RegistryStateSchema.or(z.literal('not-installed')),
1271
+ /** The operator axis alone. */
1272
+ activated: z.boolean(),
1273
+ deactivatable: z.boolean(),
1274
+ /** The module's own declared reason, rendered next to the locked control. */
1275
+ nonDeactivatableReason: z.string().nullable(),
1276
+ });
1277
+ /** `GET /api/v1/admin/module-presence` — every admin, no permission code. */
1278
+ export const AdminModulePresenceResponseSchema = z.object({
1279
+ modules: z.array(ModulePresenceSchema),
1280
+ /**
1281
+ * The serving process is TTL-refreshing from PostgreSQL because its pub/sub
1282
+ * link is unhealthy, so this projection may lag a flip made elsewhere by up
1283
+ * to `FALLBACK_TTL_MS`. Reported rather than hidden: the platform screen has
1284
+ * to be able to say "this is stale" instead of quietly showing an operator a
1285
+ * state that is no longer true.
1286
+ */
1287
+ degraded: z.boolean(),
1288
+ });
1289
+ /** The storefront needs no axis detail — only whether to render at all. */
1290
+ export const StorefrontModulePresenceSchema = z.object({
1291
+ id: z.string().regex(moduleIdRe),
1292
+ present: z.boolean(),
1293
+ });
1294
+ /** `GET /api/v1/storefront/module-presence` — public, tag `modules:presence`. */
1295
+ export const StorefrontModulePresenceResponseSchema = z.object({
1296
+ modules: z.array(StorefrontModulePresenceSchema),
1297
+ });
1298
+ /**
1299
+ * `POST /api/v1/admin/modules/:id/activation` — the operator axis, and the
1300
+ * only door to it. The ordinary settings write path refuses an activation
1301
+ * code, so this endpoint's audited Command is where every flip is recorded.
1302
+ */
1303
+ export const ModuleActivationRequestSchema = z.object({
1304
+ active: z.boolean(),
1305
+ });
1306
+ /** The module's presence *after* the flip, so no client recomputes it. */
1307
+ export const ModuleActivationResponseSchema = z.object({
1308
+ module: ModulePresenceSchema,
1309
+ });
1310
+ /**
1311
+ * `GET /api/v1/admin/modules/:id/deactivation-impact` — the live half of the
1312
+ * confirmation an operator is shown before switching a module off.
1313
+ *
1314
+ * Feature 074's consequence rows are **static**: one sentence per present
1315
+ * dependent, taken from that dependent's `whenAbsent` declaration, so the
1316
+ * dialog can be rendered from the ledger with no database read. This response
1317
+ * carries the facts that only a live read can answer, and today there is
1318
+ * exactly one — how many people hold a second factor (the owner's ruling on
1319
+ * D-96.5).
1320
+ *
1321
+ * Three properties of the shape, each deliberate:
1322
+ *
1323
+ * - **Named after the fact, not after the module.** `mfa` owns the table and
1324
+ * answers the question through a port; the wire shape says what the number
1325
+ * means. When a second module needs a live datum this becomes a list — one
1326
+ * entry per fact — which is a change to make when there are two, not now
1327
+ * (Constitution IV).
1328
+ * - **Nullable, always.** `null` means "not available", not "zero": the module
1329
+ * is already off, or the read failed. A count that cannot be fetched must
1330
+ * never stop an operator switching a module off, so the caller renders the
1331
+ * rest of the dialog and says the number is unavailable.
1332
+ * - **Read while the module is still on.** The dialog precedes the flip, so
1333
+ * the gate on the owning port is open when the question is asked. That is
1334
+ * what makes a live count implementable at all — see `MfaEnrolmentCountPort`.
1335
+ */
1336
+ export const ModuleDeactivationImpactSchema = z.object({
1337
+ moduleId: z.string().regex(moduleIdRe),
1338
+ /** Subjects with an active second factor; `null` when unavailable. */
1339
+ activeSecondFactorUsers: z
1340
+ .object({
1341
+ admins: z.number().int().nonnegative(),
1342
+ customers: z.number().int().nonnegative(),
1343
+ })
1344
+ .nullable(),
1345
+ });
1346
+ // ---- Feature 060 — API interceptor diagnostics (read-only admin) ----------
1347
+ /**
1348
+ * One row of the interceptor execution plan served by
1349
+ * `GET /api/v1/admin/api-interceptors`. Items are sorted in execution order:
1350
+ * target, then phase (pre before post), then order + (module, id) tie-break.
1351
+ */
1352
+ export const apiInterceptorEntrySchema = z.object({
1353
+ /** Endpoint identity, e.g. `POST /api/v1/orders`. */
1354
+ target: z.string(),
1355
+ phase: z.enum(['pre', 'post']),
1356
+ order: z.number().int(),
1357
+ /** Owning module id — execution is lifecycle-gated on this module. */
1358
+ module: z.string(),
1359
+ /** Interceptor id, unique within the module. */
1360
+ id: z.string(),
1361
+ /** Live enabled state of the owning module at request time. */
1362
+ moduleEnabled: z.boolean(),
1363
+ });
1364
+ export const apiInterceptorListSchema = z.object({
1365
+ items: z.array(apiInterceptorEntrySchema),
1366
+ });
1367
+ // ---------------------------------------------------------------------------
1368
+ // --- no port over the module manifests -------------------------------------
1369
+ //
1370
+ // **Which modules a deployment ships is a composition-root input, not a
1371
+ // module's port** (D-98.5). `ModuleManifestReadPort` stood here unprovided and
1372
+ // is deleted: the root builds the resolved registry and passes it *into* the
1373
+ // lifecycle orchestrator, so a `_lifecycle`-owned port over that value would
1374
+ // make the orchestrator's own input come out of the orchestrator. `_lifecycle`
1375
+ // owns what it adds — the dependency graph, the install hooks, the registry
1376
+ // rows, the operator surface — not the list. The gate could not close either:
1377
+ // `_lifecycle` is non-deactivatable, and a `providePort`'s one distinguishing
1378
+ // property over a plain registration is the 503 at the seam.
1379
+ //
1380
+ // The four modules that read manifests — `_i18n`, `admin_actions`,
1381
+ // `admin_roles`, `settings` — take `resolvedModuleRegistry` as a root-supplied
1382
+ // value and each declares the narrow view it needs. That is the rule this
1383
+ // settles: **aggregate reads build catalogues; targeted reads are refused.** A
1384
+ // `get(moduleId)` would let any module read any other module's permissions,
1385
+ // settings, palette actions and `activation` declaration and branch on them —
1386
+ // a question with no declared edge, no gate and no ledger row. Presence
1387
+ // questions go through `effectiveState`; capability questions go through a
1388
+ // port the neighbour publishes.
1389
+ // ---------------------------------------------------------------------------
1390
+ //# sourceMappingURL=modules.js.map