@synap-core/workspace-templates 0.12.0 → 0.24.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 (237) hide show
  1. package/README.md +509 -0
  2. package/automations/bookmark-enrichment.automation.json +321 -0
  3. package/automations/github-pr-events.automation.json +505 -0
  4. package/automations/mail-feed.automation.json +86 -0
  5. package/automations/ship-a-change-checks-green.automation.json +106 -0
  6. package/automations/ship-a-change-checks-red.automation.json +106 -0
  7. package/automations/ship-a-change-deployed.automation.json +56 -0
  8. package/automations/ship-a-change-pr-merged.automation.json +110 -0
  9. package/automations/ship-a-change-pr-opened.automation.json +104 -0
  10. package/automations/url-bookmark-capture.automation.json +132 -0
  11. package/capabilities/agency-skills.capability.json +157 -0
  12. package/capabilities/apify.capability.json +265 -0
  13. package/capabilities/apollo.capability.json +120 -0
  14. package/capabilities/arch-backend.capability.json +97 -0
  15. package/capabilities/arch-client-intelligence.capability.json +401 -0
  16. package/capabilities/backblaze-b2.capability.json +205 -0
  17. package/capabilities/cal-com.capability.json +266 -0
  18. package/capabilities/canva.capability.json +274 -0
  19. package/capabilities/claude-code-routines.capability.json +172 -0
  20. package/capabilities/claude-managed-agents.capability.json +215 -0
  21. package/capabilities/client-comms-report.capability.json +246 -0
  22. package/capabilities/content-idea-to-production.capability.json +29 -0
  23. package/capabilities/datagouv.capability.json +315 -0
  24. package/capabilities/discord-bot.capability.json +157 -0
  25. package/capabilities/dropbox.capability.json +281 -0
  26. package/capabilities/exa.capability.json +109 -0
  27. package/capabilities/fal-ai.capability.json +83 -0
  28. package/capabilities/fal-media-lab.capability.json +158 -0
  29. package/capabilities/fireflies.capability.json +191 -0
  30. package/capabilities/freellmapi.capability.json +114 -0
  31. package/capabilities/generic-apikey.capability.json +92 -0
  32. package/capabilities/github-merge.capability.json +99 -0
  33. package/capabilities/github-read.capability.json +325 -0
  34. package/capabilities/governance-tighten-recommender.capability.json +81 -0
  35. package/capabilities/jev.capability.json +226 -0
  36. package/capabilities/lead-conversion-enrollment.capability.json +246 -0
  37. package/capabilities/mail-feed.capability.json +277 -0
  38. package/capabilities/mailgun.capability.json +52 -0
  39. package/capabilities/nango-google.capability.json +639 -0
  40. package/capabilities/notion.capability.json +417 -0
  41. package/capabilities/omniroute.capability.json +137 -0
  42. package/capabilities/posthog-analytics.capability.json +258 -0
  43. package/capabilities/postiz.capability.json +467 -0
  44. package/capabilities/proactive-digest.capability.json +190 -0
  45. package/capabilities/proton.capability.json +288 -0
  46. package/capabilities/replicate-rvm.capability.json +97 -0
  47. package/capabilities/research-methods.capability.json +57 -0
  48. package/capabilities/shopify.capability.json +395 -0
  49. package/capabilities/slack.capability.json +369 -0
  50. package/capabilities/stripe.capability.json +1496 -0
  51. package/capabilities/telegram-bridge.capability.json +56 -0
  52. package/capabilities/treg.capability.json +102 -0
  53. package/capabilities/unipile-linkedin.capability.json +494 -0
  54. package/capabilities/vercel.capability.json +424 -0
  55. package/dist/define-DqoItRiR.d.ts +1370 -0
  56. package/dist/index.d.ts +174 -900
  57. package/dist/index.js +26447 -24075
  58. package/dist/index.js.map +1 -1
  59. package/dist/wire.d.ts +222 -0
  60. package/dist/wire.js +77 -0
  61. package/dist/wire.js.map +1 -0
  62. package/package.json +27 -4
  63. package/retired.yaml +136 -0
  64. package/skills/app-factory-build-slice/SKILL.md +70 -0
  65. package/skills/app-factory-build-slice.catalog.json +17 -0
  66. package/skills/app-factory-design-canvas/SKILL.md +96 -0
  67. package/skills/app-factory-design-canvas.catalog.json +17 -0
  68. package/skills/app-factory-design-review/SKILL.md +105 -0
  69. package/skills/app-factory-design-review.catalog.json +18 -0
  70. package/skills/app-factory-research/SKILL.md +123 -0
  71. package/skills/app-factory-research.catalog.json +17 -0
  72. package/skills/bookmark-enrichment/SKILL.md +40 -0
  73. package/skills/carousel-format/carousel-format.skill.json +18 -0
  74. package/skills/collage-broll/collage-broll.skill.json +20 -0
  75. package/skills/content-taste/content-taste.skill.json +18 -0
  76. package/skills/creative-director/creative-director.skill.json +18 -0
  77. package/skills/design-critique/SKILL.md +138 -0
  78. package/skills/design-critique.catalog.json +12 -0
  79. package/skills/designing-with-real-components/SKILL.md +140 -0
  80. package/skills/designing-with-real-components.catalog.json +12 -0
  81. package/skills/edit-loop/edit-loop.skill.json +25 -0
  82. package/skills/facecam-edit/facecam-edit.skill.json +24 -0
  83. package/skills/mapping-the-server/SKILL.md +54 -0
  84. package/skills/math-explainer-manimgx/math-explainer-manimgx.skill.json +20 -0
  85. package/skills/motion-quality-gates/motion-quality-gates.skill.json +20 -0
  86. package/skills/open-design-edit/open-design-edit.skill.json +24 -0
  87. package/skills/reading-client-progress/SKILL.md +65 -0
  88. package/skills/routing-to-channels/SKILL.md +54 -0
  89. package/skills/stellar-scf-grants-advisor/ASSOCIATE-CHEATSHEET.md +133 -0
  90. package/skills/stellar-scf-grants-advisor/ASSOCIATE-ONBOARDING-GUIDE.md +761 -0
  91. package/skills/stellar-scf-grants-advisor/CLIENT-FAQ.md +181 -0
  92. package/skills/stellar-scf-grants-advisor/PARTNER-HANDOVER.md +130 -0
  93. package/skills/stellar-scf-grants-advisor/SKILL.md +102 -0
  94. package/skills/stellar-scf-grants-advisor/examples/README.md +43 -0
  95. package/skills/stellar-scf-grants-advisor/examples/chat-summary-templates.md +158 -0
  96. package/skills/stellar-scf-grants-advisor/examples/corpus-comparable-rows.md +136 -0
  97. package/skills/stellar-scf-grants-advisor/examples/q10-team-rewrites.md +109 -0
  98. package/skills/stellar-scf-grants-advisor/examples/q2-description-rewrites.md +88 -0
  99. package/skills/stellar-scf-grants-advisor/examples/q6-integration-rewrites.md +107 -0
  100. package/skills/stellar-scf-grants-advisor/examples/tranche-structures.md +176 -0
  101. package/skills/stellar-scf-grants-advisor/reference/01-methodology.md +96 -0
  102. package/skills/stellar-scf-grants-advisor/reference/02-scoring-framework.md +199 -0
  103. package/skills/stellar-scf-grants-advisor/reference/03-rejection-patterns.md +241 -0
  104. package/skills/stellar-scf-grants-advisor/reference/04-winner-patterns.md +120 -0
  105. package/skills/stellar-scf-grants-advisor/reference/05-comeback-patterns.md +92 -0
  106. package/skills/stellar-scf-grants-advisor/reference/06-budget-benchmarks.md +125 -0
  107. package/skills/stellar-scf-grants-advisor/reference/07-integration-list.md +147 -0
  108. package/skills/stellar-scf-grants-advisor/reference/08-abstract-fields.md +258 -0
  109. package/skills/stellar-scf-grants-advisor/reference/09-build-fields.md +259 -0
  110. package/skills/stellar-scf-grants-advisor/reference/10-corpus-winners.md +135 -0
  111. package/skills/stellar-scf-grants-advisor/reference/11-corpus-rejects.md +267 -0
  112. package/skills/stellar-scf-grants-advisor/reference/12-client-communication.md +131 -0
  113. package/skills/stellar-scf-grants-advisor/reference/13-excel-output-spec.md +199 -0
  114. package/skills/stellar-scf-grants-advisor/reference/14-worked-examples.md +160 -0
  115. package/skills/stellar-scf-grants-advisor/templates/abstract-review-builder.py +253 -0
  116. package/skills/stellar-scf-grants-advisor/templates/build-review-builder.py +213 -0
  117. package/skills/video-direction/video-direction.skill.json +20 -0
  118. package/skills/video-style-vocabulary/video-style-vocabulary.skill.json +20 -0
  119. package/skills/video-toolbox/video-toolbox.skill.json +20 -0
  120. package/src/__fixtures__/lifecycle-presets.flat-before.json +205 -0
  121. package/src/agency-os.yaml +122 -0
  122. package/src/agent-os-skill-slugs.test.ts +65 -0
  123. package/src/apify-social-reader.test.ts +317 -0
  124. package/src/base.yaml +85 -39
  125. package/src/blockchain-ecosystem.yaml +1 -0
  126. package/src/brand-library.yaml +525 -14
  127. package/src/builder-dev-fold.template.test.ts +113 -0
  128. package/src/builder.yaml +779 -91
  129. package/src/business-model-catalog-description.test.ts +47 -0
  130. package/src/business-model-grp.template.test.ts +214 -0
  131. package/src/business-model.yaml +296 -49
  132. package/src/catalog-safety.test.ts +198 -0
  133. package/src/catalog.template-items.test.ts +75 -0
  134. package/src/catalog.ts +66 -2
  135. package/src/concept-guard.test.ts +28 -8
  136. package/src/concept-guard.ts +2 -2
  137. package/src/content-creation.template.test.ts +52 -0
  138. package/src/content-creation.yaml +192 -0
  139. package/src/content-os.edit-loop.template.test.ts +210 -0
  140. package/src/content-os.publish-due-posts.template.test.ts +261 -0
  141. package/src/content-os.template.test.ts +349 -0
  142. package/src/content-os.video-takes.template.test.ts +338 -0
  143. package/src/content-os.work-a-take.template.test.ts +125 -0
  144. package/src/content-os.yaml +1873 -39
  145. package/src/content-studio-retired.template.test.ts +82 -0
  146. package/src/corpus-flow-validity.test.ts +4 -5
  147. package/src/crm-absorbs-outreach.template.test.ts +117 -0
  148. package/src/crm-build-plan.template.test.ts +128 -0
  149. package/src/crm-lists.template.test.ts +96 -0
  150. package/src/crm-messages-cadence.template.test.ts +246 -0
  151. package/src/crm-one-send-door.template.test.ts +65 -0
  152. package/src/crm.yaml +1563 -181
  153. package/src/decisions-wave.template.test.ts +186 -0
  154. package/src/define.ts +146 -17
  155. package/src/design-to-implementation.template.test.ts +94 -0
  156. package/src/domain-template-type.test.ts +75 -0
  157. package/src/ecosystem.yaml +27 -66
  158. package/src/enterprise-os.yaml +10 -134
  159. package/src/facecam-edit.audio-studio.test.ts +188 -0
  160. package/src/fal-generate-image.test.ts +200 -0
  161. package/src/fal-media-lab-pod-files.test.ts +180 -0
  162. package/src/finance.yaml +254 -1
  163. package/src/foundation.yaml +78 -38
  164. package/src/frame-host-modules.d.mts +6 -0
  165. package/src/frame-host-modules.mjs +58 -0
  166. package/src/generate.mjs +9 -3
  167. package/src/github-merge.test.ts +210 -0
  168. package/src/grants.yaml +46 -17
  169. package/src/hr.yaml +39 -6
  170. package/src/index.ts +36 -1
  171. package/src/intent.test.ts +161 -0
  172. package/src/internal-runbook.yaml +19 -1
  173. package/src/jev.test.ts +356 -0
  174. package/src/legal.yaml +33 -1
  175. package/src/life-os.yaml +51 -240
  176. package/src/lifecycle-coverage.test.ts +124 -0
  177. package/src/lifecycle-declarations.test.ts +252 -0
  178. package/src/lifecycle-presets.test.ts +468 -0
  179. package/src/lifecycle-presets.ts +494 -0
  180. package/src/lifecycle-stuck.test.ts +46 -0
  181. package/src/lifecycle-waits.parity.test.ts +319 -0
  182. package/src/marketing.yaml +108 -4
  183. package/src/meetings-knowledge.yaml +18 -0
  184. package/src/networking.yaml +23 -2
  185. package/src/operations.yaml +304 -4
  186. package/src/pipeline-views.template.test.ts +88 -0
  187. package/src/playbook-goal-placeholders.test.ts +57 -0
  188. package/src/postiz-contract.test.ts +134 -0
  189. package/src/presentation-declarations.test.ts +174 -0
  190. package/src/process-contract.validate.test.ts +352 -0
  191. package/src/project-management.yaml +39 -740
  192. package/src/publish-body.d.mts +29 -0
  193. package/src/publish-body.mjs +74 -0
  194. package/src/publish-body.test.ts +74 -0
  195. package/src/publish-official-lib.test.ts +176 -0
  196. package/src/replicate-rvm.test.ts +504 -0
  197. package/src/research-base.yaml +119 -20
  198. package/src/research-decision.yaml +60 -3
  199. package/src/reserved-profile-slugs.test.ts +81 -0
  200. package/src/retired-goal-kinds.test.ts +52 -0
  201. package/src/role-reference-and-step-domain.test.ts +192 -0
  202. package/src/seed-obligation.test.ts +1 -6
  203. package/src/send-message-maps.test.ts +227 -0
  204. package/src/shared-role-ssot.test.ts +40 -2
  205. package/src/ship-a-change.template.test.ts +177 -0
  206. package/src/skills.test.ts +170 -0
  207. package/src/social.yaml +85 -27
  208. package/src/space-packages.freshness.test.ts +97 -0
  209. package/src/space-packages.generated.ts +40 -0
  210. package/src/space-packages.test.ts +125 -0
  211. package/src/space-packages.ts +73 -0
  212. package/src/start.test.ts +188 -0
  213. package/src/stripe-capability.test.ts +192 -0
  214. package/src/task-intents.test.ts +293 -0
  215. package/src/template-documents.test.ts +158 -0
  216. package/src/templates.ts +20907 -21464
  217. package/src/types.ts +475 -61
  218. package/src/unipile-linkedin-find-profile.test.ts +183 -0
  219. package/src/unipile-publish-post.test.ts +162 -0
  220. package/src/validate-flow.drift.test.ts +2 -3
  221. package/src/validate-flow.ts +22 -2
  222. package/src/validate-onboarding.test.ts +59 -0
  223. package/src/validate.test.ts +71 -4
  224. package/src/validate.ts +1416 -7
  225. package/src/wire-core.ts +231 -0
  226. package/src/wire.test.ts +159 -0
  227. package/src/wire.ts +107 -0
  228. package/views/bookmark-views-pack.view.json +39 -0
  229. package/views/event-views-pack.view.json +45 -0
  230. package/views/task-views-pack.view.json +53 -0
  231. package/src/agent-fleet.yaml +0 -1645
  232. package/src/business-developer.template.test.ts +0 -351
  233. package/src/business-developer.yaml +0 -860
  234. package/src/content-studio.yaml +0 -361
  235. package/src/dev-dashboard.yaml +0 -990
  236. package/src/outreach-comms.yaml +0 -486
  237. package/src/the-arch.yaml +0 -201
package/src/validate.ts CHANGED
@@ -31,6 +31,21 @@
31
31
  * so it is always present. It is DB-free (only `import type` internally), so it
32
32
  * needs no injection: the flow-node contract check runs for every caller
33
33
  * (build / check-converters / CLI / CP) automatically.
34
+ *
35
+ * A SECOND RUNTIME IMPORT: `@synap-core/types/capability-intents`, for the
36
+ * `taskIntents` membership check. This one resolves from the linked workspace
37
+ * package rather than a sibling file, and it is a LEAF with no generated
38
+ * dependency, so it is present under the type-stripper for the same reason
39
+ * `./validate-flow.ts` is. It is intentionally NOT injected like the
40
+ * converters: the intent vocabulary is a leaf VALUE, not a compiled template
41
+ * converter, and a validator that silently skips its check when a caller forgot
42
+ * to pass converters would be worse than one that resolves a constant.
43
+ *
44
+ * A THIRD RUNTIME IMPORT: `@synap-core/types/space-brief`, for the declared
45
+ * `skills[]` shape check. Same class as `capability-intents` — a pure,
46
+ * dependency-free leaf resolved from the linked workspace package. The rule is
47
+ * the SAME `validateSpaceSkillDeclaration` the pod's applier runs, so authoring
48
+ * and install cannot drift on what a skill declaration is.
34
49
  */
35
50
 
36
51
  import type {
@@ -41,6 +56,28 @@ import type {
41
56
 
42
57
  // eslint-disable-next-line import/extensions -- .ts extension is intentional; see header.
43
58
  import { validateFlowDefinition } from "./validate-flow.ts";
59
+ // eslint-disable-next-line import/extensions -- .ts extension is intentional; see header.
60
+ import { INTENT_LABEL_MAX, START_EXAMPLE_RANGE, START_STARTERS_RANGE, START_VIEWS_MAX } from "./types.ts";
61
+
62
+ import {
63
+ CAPABILITY_INTENTS,
64
+ unknownIntents,
65
+ } from "@synap-core/types/capability-intents";
66
+
67
+ import { validateSpaceSkillDeclaration } from "@synap-core/types/space-brief";
68
+
69
+ // A FOURTH runtime import, same leaf class as the two above: the ONE
70
+ // presentation parser. `tsup.config.ts` BUNDLES this subpath into `dist/`
71
+ // (`noExternal`), so a consumer whose resolved `@synap-core/types` predates
72
+ // `./presentation` (the CP, the CLI) still loads the validator.
73
+ import { declaredMediaSources, parseProfilePresentation } from "@synap-core/types/presentation";
74
+
75
+ // A FIFTH runtime import, a sibling leaf like `./validate-flow.ts` (only
76
+ // `import type` inside): the lifecycle preset compiler. Every rule below reads
77
+ // the declaration the pod will store, so `adopts:` overlays and omitted
78
+ // `enumValues` are compiled first (`validateTemplate`, "Lifecycle presets").
79
+ // eslint-disable-next-line import/extensions -- .ts extension is intentional; see header.
80
+ import { resolveLifecyclePresets } from "./lifecycle-presets.ts";
44
81
 
45
82
  // ── Result shape ─────────────────────────────────────────────────────────────
46
83
 
@@ -157,6 +194,8 @@ export const SYSTEM_PROFILES = new Set([
157
194
  // was simply missing from this hand-maintained list; the flow's
158
195
  // `create-report` node references it, so referential integrity needs it here.
159
196
  "report",
197
+ // Demand signal — a tool the user uses that Synap cannot connect yet
198
+ "tool_request",
160
199
  ]);
161
200
 
162
201
  /**
@@ -172,6 +211,19 @@ export const SYSTEM_PROFILES = new Set([
172
211
  * schema and its migration are owned by the backend seeder.
173
212
  */
174
213
  const TEMPLATE_FORBIDDEN_SYSTEM_PROFILE_SLUGS = new Set(["knowledge"]);
214
+ /**
215
+ * Profile slugs the pod refuses on every profile write path, because the
216
+ * concept already has a first-class home elsewhere: projects live in the
217
+ * `projects` TABLE (migration 0151), not as an entity kind. A template that
218
+ * declares one can never install it — `ProfileRepository.create` throws — so
219
+ * the declaration is dead at best and a failed install at worst.
220
+ *
221
+ * MIRROR of `RESERVED_PROFILE_SLUGS` in
222
+ * `synap-backend/packages/database/src/utils/reserved-profile-slugs.ts` (this
223
+ * package cannot import `@synap/database`). `reserved-profile-slugs.test.ts`
224
+ * pins the two sets equal.
225
+ */
226
+ export const RESERVED_PROFILE_SLUGS: ReadonlySet<string> = new Set(["project", "projects"]);
175
227
  const KNOWLEDGE_FORMS = new Set(["insight", "caution"]);
176
228
  const LEGACY_KNOWLEDGE_SEED_FIELDS = new Set(["ek_type", "type"]);
177
229
 
@@ -230,6 +282,31 @@ export function collectBaseProfileSlugs(
230
282
  return out;
231
283
  }
232
284
 
285
+ /** A template document slug: kebab-case. Same rule as the pod's install door (`schemas/template-document.ts`). */
286
+ export const TEMPLATE_DOCUMENT_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
287
+
288
+ /**
289
+ * The document slugs of every base this template COMPOSES onto, transitively —
290
+ * an overlay installs into its base's space, so its sidebar may name them. A
291
+ * `require` base keeps its own space: its documents are not reachable.
292
+ */
293
+ function composeBaseDocumentSlugs(
294
+ data: WorkspaceYaml,
295
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
296
+ seen = new Set<string>()
297
+ ): Set<string> {
298
+ const out = new Set<string>();
299
+ for (const dep of data.dependencies || []) {
300
+ if (dep.relation !== "compose" || seen.has(dep.slug)) continue;
301
+ seen.add(dep.slug);
302
+ const base = resolveDep(dep.slug);
303
+ if (!base) continue;
304
+ for (const d of base.documents || []) if (d?.slug) out.add(d.slug);
305
+ for (const s of composeBaseDocumentSlugs(base, resolveDep, seen)) out.add(s);
306
+ }
307
+ return out;
308
+ }
309
+
233
310
  /**
234
311
  * Recursively collect every `profileSlug` string value under `node`, tagged with
235
312
  * a dotted path for error messages. Used for `automations` (whose flow-node /
@@ -257,6 +334,34 @@ function collectProfileSlugRefs(
257
334
  }
258
335
  }
259
336
 
337
+ /**
338
+ * `opts` with every corpus and dependency template compiled by
339
+ * `resolveLifecyclePresets`. Memoised per object, so the same authored template
340
+ * always maps to the same compiled one (`profileDeclarations` dedupes by
341
+ * identity) and `authored` maps to the `tpl` being validated.
342
+ */
343
+ function withResolvedLifecycles(
344
+ opts: ValidateOptions,
345
+ authored: WorkspaceYaml,
346
+ tpl: WorkspaceYaml
347
+ ): ValidateOptions {
348
+ const compiled = new WeakMap<WorkspaceYaml, WorkspaceYaml>([[authored, tpl]]);
349
+ const compile = (t: WorkspaceYaml): WorkspaceYaml => {
350
+ let out = compiled.get(t);
351
+ if (!out) compiled.set(t, (out = resolveLifecyclePresets(t).template));
352
+ return out;
353
+ };
354
+ const { resolveDep } = opts;
355
+ return {
356
+ ...opts,
357
+ corpus: opts.corpus?.map(compile),
358
+ resolveDep: resolveDep && ((slug: string) => {
359
+ const t = resolveDep(slug);
360
+ return t && compile(t);
361
+ }),
362
+ };
363
+ }
364
+
260
365
  function resolveDepFrom(
261
366
  opts: ValidateOptions
262
367
  ): (slug: string) => WorkspaceYaml | undefined {
@@ -272,6 +377,950 @@ function resolveDepFrom(
272
377
  return () => undefined;
273
378
  }
274
379
 
380
+ // ── Shared-role references (W4a) ─────────────────────────────────────────────
381
+
382
+ /** The template that owns the pod-wide shared ROLE vocabulary (concepts.md: Role). */
383
+ export const SHARED_ROLE_SSOT_SLUG = "foundation";
384
+
385
+ const isSharedRole = (p: TemplateProfile): boolean =>
386
+ isPodWideProfile(p) && p.profileKind === "role";
387
+
388
+ /**
389
+ * A shared role declared WITHOUT `applicableKinds` is a REFERENCE: it names the
390
+ * one pod-wide role another template owns and adds at most workspace-overlay
391
+ * properties. The owner always declares `applicableKinds` (shared-role-ssot
392
+ * rule d), so its absence is the reference marker.
393
+ */
394
+ const isRoleReference = (p: TemplateProfile): boolean =>
395
+ isSharedRole(p) && !(p.applicableKinds && p.applicableKinds.length > 0);
396
+
397
+ /** Every template slug reachable through `dependencies` (compose + require, transitive). */
398
+ export function collectDependencyClosure(
399
+ tpl: WorkspaceYaml,
400
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
401
+ seen = new Set<string>()
402
+ ): Set<string> {
403
+ for (const dep of tpl.dependencies || []) {
404
+ if (dep.relation !== "compose" && dep.relation !== "require") continue;
405
+ if (seen.has(dep.slug)) continue;
406
+ seen.add(dep.slug);
407
+ const base = resolveDep(dep.slug);
408
+ if (base) collectDependencyClosure(base, resolveDep, seen);
409
+ }
410
+ return seen;
411
+ }
412
+
413
+ /**
414
+ * A template that REFERENCES a shared role (or re-declares one of foundation's)
415
+ * must reach foundation through its dependency chain. The install engine keeps
416
+ * the pod-wide row's body from whichever template creates it FIRST, and deps
417
+ * install first — so without foundation in the chain a reference, installed on
418
+ * a pod that lacks foundation, would mint the pod-wide role with NO body (no
419
+ * applicableKinds ⇒ attachable to any kind, no properties).
420
+ */
421
+ function checkSharedRoleReferences(
422
+ tpl: WorkspaceYaml,
423
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
424
+ add: (rule: string, path: string, message: string) => void
425
+ ): void {
426
+ if (tpl.meta?.slug === SHARED_ROLE_SSOT_SLUG) return;
427
+ const ssot = resolveDep(SHARED_ROLE_SSOT_SLUG);
428
+ const ssotRoles = new Set(
429
+ (ssot?.profiles ?? []).filter(isSharedRole).map((p) => p.slug)
430
+ );
431
+ const closure = collectDependencyClosure(tpl, resolveDep);
432
+ for (const p of tpl.profiles || []) {
433
+ if (!isSharedRole(p)) continue;
434
+ const reference = isRoleReference(p);
435
+ if (!reference && !ssotRoles.has(p.slug)) continue; // a role this template owns
436
+ if (!closure.has(SHARED_ROLE_SSOT_SLUG)) {
437
+ add(
438
+ "shared-role-reference-no-foundation",
439
+ `profiles.${p.slug}`,
440
+ `shared role "${p.slug}" is foundation's — "${SHARED_ROLE_SSOT_SLUG}" must be in this template's dependency chain (compose or require, transitively), else an install on a pod without it mints the role with no body`
441
+ );
442
+ }
443
+ if (reference && ssot && !ssotRoles.has(p.slug)) {
444
+ add(
445
+ "shared-role-reference-unknown",
446
+ `profiles.${p.slug}`,
447
+ `shared role "${p.slug}" is declared as a reference (no applicableKinds) but "${SHARED_ROLE_SSOT_SLUG}" does not define it — declare the full role (with applicableKinds) where it is owned`
448
+ );
449
+ }
450
+ }
451
+ }
452
+
453
+ // ── Step domains (W4a) ───────────────────────────────────────────────────────
454
+
455
+ /** Same shape the pod's `playbookStageSchema.domain` accepts (playbook-stage.ts). */
456
+ const STAGE_DOMAIN_SHAPE = /^@?[a-z0-9][a-z0-9._/-]*$/;
457
+ const UUID_SHAPE =
458
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
459
+
460
+ /** A template that never owns a workspace: an overlay (compose) or a pack (suite). */
461
+ function ownsNoWorkspace(t: WorkspaceYaml): string | null {
462
+ if ((t.dependencies ?? []).some((d) => d.relation === "compose"))
463
+ return "an overlay (it composes onto another workspace)";
464
+ if ((t.meta?.tags ?? []).includes("suite")) return "a pack";
465
+ return null;
466
+ }
467
+
468
+ /**
469
+ * `stage.domain` names the workspace TEMPLATE a step is worked in; the pod
470
+ * matches it against `workspaces.package_slug` (tracks/stage-domain.ts). So it
471
+ * must be slug-shaped (the pod door refuses anything else — failing here is
472
+ * cheaper than failing at install), name a template the corpus knows, and name
473
+ * one that OWNS a workspace: an overlay's or a pack's slug is never any
474
+ * workspace's package_slug, so such a step would always fall back to the
475
+ * project home.
476
+ */
477
+ function checkStageDomains(
478
+ tpl: WorkspaceYaml,
479
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
480
+ add: (rule: string, path: string, message: string) => void
481
+ ): void {
482
+ for (const pb of tpl.playbooks || []) {
483
+ for (const stage of pb.stages || []) {
484
+ const domain = stage.domain;
485
+ if (domain === undefined) continue;
486
+ const path = `playbooks."${pb.name}".stages.${stage.key}.domain`;
487
+ if (
488
+ typeof domain !== "string" ||
489
+ !STAGE_DOMAIN_SHAPE.test(domain) ||
490
+ UUID_SHAPE.test(domain)
491
+ ) {
492
+ add(
493
+ "stage-domain-shape",
494
+ path,
495
+ `step domain "${String(domain)}" must be a workspace template slug (lowercase, e.g. "crm"), not a workspace id or name`
496
+ );
497
+ continue;
498
+ }
499
+ const target =
500
+ domain === tpl.meta?.slug ? tpl : resolveDep(domain);
501
+ if (!target) {
502
+ // Only knowable with a corpus; a lone-template validate cannot tell.
503
+ if (hasCorpus(resolveDep))
504
+ add(
505
+ "stage-domain-unknown",
506
+ path,
507
+ `step domain "${domain}" is not a workspace template in the corpus`
508
+ );
509
+ continue;
510
+ }
511
+ const why = ownsNoWorkspace(target);
512
+ if (why) {
513
+ add(
514
+ "stage-domain-not-a-domain",
515
+ path,
516
+ `step domain "${domain}" is ${why} — it never owns a workspace, so the step could never resolve; name the workspace template it lands on`
517
+ );
518
+ }
519
+ }
520
+ }
521
+ }
522
+
523
+ /**
524
+ * D8 (2026-09-25): a pack lists the WORKSPACE TEMPLATES it needs — never another
525
+ * pack. A pack-on-pack dependency hides which workspace templates are really
526
+ * installed and re-introduces the stray suite workspace D8 retired.
527
+ */
528
+ function checkPackDependencies(
529
+ tpl: WorkspaceYaml,
530
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
531
+ add: (rule: string, path: string, message: string) => void
532
+ ): void {
533
+ if (!(tpl.meta?.tags ?? []).includes("suite")) return;
534
+ for (const dep of tpl.dependencies || []) {
535
+ const target = resolveDep(dep.slug);
536
+ if (target && (target.meta?.tags ?? []).includes("suite")) {
537
+ add(
538
+ "pack-requires-pack",
539
+ `dependencies.${dep.slug}`,
540
+ `pack "${tpl.meta?.slug}" depends on pack "${dep.slug}" — list the workspace templates "${dep.slug}" needs instead (D8)`
541
+ );
542
+ }
543
+ }
544
+ }
545
+
546
+ // ── Process contract (lifecycle-bound playbooks) ─────────────────────────────
547
+
548
+ /** The `relationToSubject` value that deliberately writes no edge. */
549
+ export const NO_SUBJECT_RELATION = "none";
550
+
551
+ /**
552
+ * Every declaration of profile `slug` this template can see, own first: its
553
+ * own block, then its dependency closure, then (with a corpus) any template.
554
+ * A kind can be OWNED by a dependency (a `scope: shared` body), so the status
555
+ * property is not necessarily declared where the playbook is.
556
+ */
557
+ function profileDeclarations(
558
+ slug: string,
559
+ tpl: WorkspaceYaml,
560
+ resolveDep: (s: string) => WorkspaceYaml | undefined,
561
+ corpus: WorkspaceYaml[] | undefined
562
+ ): TemplateProfile[] {
563
+ const sources: WorkspaceYaml[] = [tpl];
564
+ for (const dep of collectDependencyClosure(tpl, resolveDep)) {
565
+ const t = resolveDep(dep);
566
+ if (t) sources.push(t);
567
+ }
568
+ for (const t of corpus ?? []) if (!sources.includes(t)) sources.push(t);
569
+ return sources.flatMap((t) =>
570
+ (t.profiles ?? []).filter((p) => p.slug === slug)
571
+ );
572
+ }
573
+
574
+ /**
575
+ * The relation types this template can name: the corpus's `entityLinks[].type`
576
+ * and `relationDefs[].slug` (the defaults live in `base.yaml`'s relationDefs,
577
+ * mirrored by `__fixtures__/default-relation-defs.json`). Without a corpus:
578
+ * own + dependency closure + `base`.
579
+ */
580
+ export function knownRelationTypes(
581
+ tpl: WorkspaceYaml,
582
+ resolveDep: (s: string) => WorkspaceYaml | undefined,
583
+ corpus: WorkspaceYaml[] | undefined
584
+ ): Set<string> {
585
+ const sources: WorkspaceYaml[] = corpus ? [...corpus] : [];
586
+ if (!sources.includes(tpl)) sources.push(tpl);
587
+ if (!corpus) {
588
+ for (const dep of collectDependencyClosure(tpl, resolveDep)) {
589
+ const t = resolveDep(dep);
590
+ if (t) sources.push(t);
591
+ }
592
+ const base = resolveDep("base");
593
+ if (base) sources.push(base);
594
+ }
595
+ const out = new Set<string>();
596
+ for (const t of sources) {
597
+ for (const el of t.entityLinks ?? []) if (el.type) out.add(el.type);
598
+ for (const rd of t.relationDefs ?? []) if (rd.slug) out.add(rd.slug);
599
+ }
600
+ return out;
601
+ }
602
+
603
+ /**
604
+ * The process contract a playbook declares on top of its stages:
605
+ * - `subjectProfile.statusProperty` is a SELECT property of the subject kind;
606
+ * - every `stages[].subjectStatus`, `activators[].status` and
607
+ * `humanOnlyStatuses[]` is one of that property's enum values;
608
+ * - `onFail.toStage` names a stage (key or name) of the same playbook;
609
+ * - every `relationToSubject` (playbook- and stage-level outputs) is a
610
+ * relation the corpus defines, or `none`.
611
+ * Each of these, if wrong, is stored by the pod and then silently never fires
612
+ * (a status write the select refuses, an activator rule on a value no entity
613
+ * ever enters, an edge type nothing renders).
614
+ */
615
+ /**
616
+ * The most criteria a session keeps. MIRROR of `MAX_SESSION_CRITERIA` in
617
+ * `synap-backend/packages/playbooks/src/index.ts` (this package cannot import
618
+ * `@synap/playbooks`): `collectPlaybookCriteria` gathers the playbook-level list
619
+ * then every stage's, keeps a repeated key once, and `readCriteria` drops
620
+ * everything past this cap without a word. `process-contract.validate.test.ts`
621
+ * pins the two values equal.
622
+ */
623
+ export const MAX_SESSION_CRITERIA = 12;
624
+
625
+ /**
626
+ * The activator triggers the pod compiles (work model L4) — the schema's
627
+ * `ACTIVATOR_ON` (`schema/playbook.ts`), which this build cannot import
628
+ * (rootDir is `src`). `schema/mirror.tripwire.test.ts` pins the two equal,
629
+ * and both to the pod's `playbookActivatorSchema`.
630
+ */
631
+ export const TEMPLATE_ACTIVATOR_ON = ["created", "enters_status", "count", "schedule", "manual"] as const;
632
+
633
+ function checkProcessContract(
634
+ tpl: WorkspaceYaml,
635
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
636
+ corpus: WorkspaceYaml[] | undefined,
637
+ add: (rule: string, path: string, message: string) => void
638
+ ): void {
639
+ let relations: Set<string> | null = null;
640
+ const relationsOnce = () =>
641
+ (relations ??= knownRelationTypes(tpl, resolveDep, corpus));
642
+
643
+ for (const pb of tpl.playbooks || []) {
644
+ const at = `playbooks."${pb.name}"`;
645
+ const kind = pb.subjectProfile?.profileSlug;
646
+ const statusProperty = pb.subjectProfile?.statusProperty;
647
+
648
+ // Resolve the lifecycle vocabulary (null = no status property to check against).
649
+ let statuses: Set<string> | null = null;
650
+ if (statusProperty !== undefined) {
651
+ if (!kind) {
652
+ add(
653
+ "playbook-status-property-no-subject",
654
+ `${at}.subjectProfile.statusProperty`,
655
+ `playbook "${pb.name}" declares statusProperty "${statusProperty}" without a subjectProfile.profileSlug`
656
+ );
657
+ } else {
658
+ const decls = profileDeclarations(kind, tpl, resolveDep, corpus);
659
+ const prop = decls
660
+ .flatMap((p) => p.properties ?? [])
661
+ .find((pr) => pr.slug === statusProperty);
662
+ if (!prop) {
663
+ // A kind no declaration in reach carries (unknown kind is reported
664
+ // by playbook-ref-unknown; don't double-report it here).
665
+ if (decls.length > 0 || hasCorpus(resolveDep))
666
+ add(
667
+ "playbook-status-property-unknown",
668
+ `${at}.subjectProfile.statusProperty`,
669
+ `playbook "${pb.name}" statusProperty "${statusProperty}" is not a property of kind "${kind}"`
670
+ );
671
+ } else if (
672
+ prop.inputType !== "select" ||
673
+ !Array.isArray(prop.enumValues) ||
674
+ prop.enumValues.length === 0
675
+ ) {
676
+ add(
677
+ "playbook-status-property-not-select",
678
+ `${at}.subjectProfile.statusProperty`,
679
+ `playbook "${pb.name}" statusProperty "${kind}.${statusProperty}" must be a select property with enumValues (found inputType "${String(prop.inputType)}")`
680
+ );
681
+ } else {
682
+ statuses = new Set(prop.enumValues);
683
+ }
684
+ }
685
+ }
686
+ const checkStatus = (rule: string, path: string, value: unknown) => {
687
+ if (statusProperty === undefined) {
688
+ add(
689
+ `${rule}-no-property`,
690
+ path,
691
+ `${path} = "${String(value)}" needs subjectProfile.statusProperty — a status is a value OF that property`
692
+ );
693
+ } else if (statuses && !statuses.has(value as string)) {
694
+ add(
695
+ rule,
696
+ path,
697
+ `${path} = "${String(value)}" is not a value of ${kind}.${statusProperty} (${[...statuses].join(", ")})`
698
+ );
699
+ }
700
+ };
701
+
702
+ const stages = pb.stages ?? [];
703
+ const stageIds = new Set(stages.flatMap((s) => [s.key, s.name]));
704
+
705
+ const criterionKeys = new Set(
706
+ [...(pb.criteria ?? []), ...stages.flatMap((s) => s.criteria ?? [])]
707
+ .map((c) => (c as { key?: unknown } | null)?.key)
708
+ .filter((k): k is string => typeof k === "string" && k.trim() !== "")
709
+ );
710
+ if (criterionKeys.size > MAX_SESSION_CRITERIA)
711
+ add(
712
+ "playbook-criteria-over-session-cap",
713
+ `${at}.criteria`,
714
+ `playbook "${pb.name}" declares ${criterionKeys.size} criteria (playbook-level plus every stage's) — a session keeps ${MAX_SESSION_CRITERIA} and drops the rest silently`
715
+ );
716
+ for (const stage of stages) {
717
+ const sp = `${at}.stages.${stage.key}`;
718
+ if (stage.subjectStatus !== undefined)
719
+ checkStatus("stage-subject-status-unknown", `${sp}.subjectStatus`, stage.subjectStatus);
720
+ const toStage = stage.onFail?.toStage;
721
+ if (stage.onFail !== undefined && (!toStage || !stageIds.has(toStage)))
722
+ add(
723
+ "stage-onfail-unknown",
724
+ `${sp}.onFail.toStage`,
725
+ `stage "${stage.key}" onFail.toStage "${String(toStage)}" names no stage of playbook "${pb.name}"`
726
+ );
727
+ }
728
+
729
+ (pb.activators ?? []).forEach((a, i) => {
730
+ const ap = `${at}.activators[${i}]`;
731
+ if (!(TEMPLATE_ACTIVATOR_ON as readonly string[]).includes(a.on))
732
+ add("activator-on-invalid", `${ap}.on`, `${ap}.on "${String(a.on)}" must be one of ${TEMPLATE_ACTIVATOR_ON.join(", ")}`);
733
+ if (a.mode !== "run" && a.mode !== "propose")
734
+ add("activator-mode-invalid", `${ap}.mode`, `${ap}.mode "${String(a.mode)}" must be "run" or "propose"`);
735
+ if (!kind)
736
+ add("activator-no-subject", ap, `${ap} needs subjectProfile.profileSlug — an activator fires on an entity of that kind`);
737
+ if (a.on === "enters_status" || a.on === "count") {
738
+ if (a.status === undefined)
739
+ add("activator-status-required", `${ap}.status`, `${ap} on "${a.on}" names no status`);
740
+ else checkStatus("activator-status-unknown", `${ap}.status`, a.status);
741
+ } else if (a.on === "schedule") {
742
+ if (a.status !== undefined) checkStatus("activator-status-unknown", `${ap}.status`, a.status);
743
+ } else if (a.status !== undefined) {
744
+ add("activator-status-unexpected", `${ap}.status`, `${ap}.status is not read when on = "${a.on}"`);
745
+ }
746
+ });
747
+
748
+ (pb.humanOnlyStatuses ?? []).forEach((s, i) =>
749
+ checkStatus("human-only-status-unknown", `${at}.humanOnlyStatuses[${i}]`, s)
750
+ );
751
+
752
+ const outputs: Array<{ path: string; rel: unknown; optional: unknown; at: string }> = [
753
+ ...(pb.expectedOutputs ?? []).map((o, i) => ({
754
+ at: `${at}.expectedOutputs[${i}]`,
755
+ path: `${at}.expectedOutputs[${i}].relationToSubject`,
756
+ rel: o.relationToSubject,
757
+ optional: o.optional,
758
+ })),
759
+ ...stages.flatMap((s) =>
760
+ (s.expectedOutputs ?? []).map((o, i) => ({
761
+ at: `${at}.stages.${s.key}.expectedOutputs[${i}]`,
762
+ path: `${at}.stages.${s.key}.expectedOutputs[${i}].relationToSubject`,
763
+ rel: o.relationToSubject,
764
+ optional: o.optional,
765
+ }))
766
+ ),
767
+ ];
768
+ // `optional` is read STRICTLY by the pod (only a real `true` counts), so a
769
+ // quoted "true" would leave the slot owed and a quoted "false" looks like a
770
+ // decision nobody's code reads.
771
+ for (const o of outputs)
772
+ if (o.optional !== undefined && typeof o.optional !== "boolean")
773
+ add("output-optional-not-boolean", `${o.at}.optional`, `${o.at}.optional = ${JSON.stringify(o.optional)} must be true or false — the pod treats anything but a real true as required`);
774
+ for (const { path, rel } of outputs) {
775
+ if (rel === undefined || rel === NO_SUBJECT_RELATION) continue;
776
+ if (!kind) {
777
+ add("output-relation-no-subject", path, `${path} = "${String(rel)}" but playbook "${pb.name}" has no subject to relate to (use "${NO_SUBJECT_RELATION}")`);
778
+ } else if (typeof rel !== "string" || !relationsOnce().has(rel)) {
779
+ add("output-relation-unknown", path, `${path} = "${String(rel)}" is not a relation the corpus defines (entityLinks[].type / relationDefs[].slug)`);
780
+ }
781
+ }
782
+ }
783
+ }
784
+
785
+ // ── Lifecycle declarations (work model L1) ──────────────────────────────────
786
+
787
+ /**
788
+ * The fixed lifecycle categories. MIRROR of `LIFECYCLE_CATEGORIES` in
789
+ * `@synap-core/types/lifecycle` (and the pod's `utils/lifecycle-model.ts`):
790
+ * this validator also runs from the published dist on the CP, whose resolved
791
+ * `@synap-core/types` may predate the `./lifecycle` subpath, so it is not
792
+ * imported. `lifecycle-declarations.test.ts` pins the two lists equal.
793
+ */
794
+ export const LIFECYCLE_CATEGORIES = ["open", "active", "paused", "done", "lost"] as const;
795
+
796
+ /** MIRROR of the pod's `STUCK_AFTER_DAYS_MAX` (`utils/lifecycle-model.ts`), same reason as above. */
797
+ export const STUCK_AFTER_DAYS_MAX = 3650;
798
+
799
+ /**
800
+ * The fields of a lifecycle option and of a step's declared wait. MIRROR of
801
+ * `LifecycleOption` / `LifecycleOptionWaits` (`@synap-core/types/lifecycle`,
802
+ * `option.ts`), same reason as above; `lifecycle-waits.parity.test.ts` pins
803
+ * them to that source and to the pod's parser.
804
+ */
805
+ export const LIFECYCLE_OPTION_FIELDS = [
806
+ "key",
807
+ "label",
808
+ "category",
809
+ "tone",
810
+ "humanOnly",
811
+ "stuckAfterDays",
812
+ "dateBinding",
813
+ "waits",
814
+ ] as const;
815
+ export const LIFECYCLE_OPTION_WAITS_FIELDS = ["on", "waitKind", "refFrom", "dateBinding"] as const;
816
+
817
+ /**
818
+ * What a step may say it waits on: MIRROR of the pod's `HOLD_ON` without
819
+ * `unspecified` (`parseOptionWaits`, `utils/lifecycle-model.ts`: a step that
820
+ * says `unspecified` says nothing, and the pod refuses it).
821
+ */
822
+ export const LIFECYCLE_WAITS_ON = ["person", "agent", "external", "thing", "decision", "date", "nobody"] as const;
823
+
824
+ /** MIRROR of `WAIT_KINDS` (`@synap-core/types/units`, `wait-kind.ts`), same reason as above. */
825
+ export const LIFECYCLE_WAIT_KINDS = [
826
+ "answer",
827
+ "approval",
828
+ "send",
829
+ "try_it",
830
+ "access",
831
+ "decide",
832
+ "their_reply",
833
+ "their_document",
834
+ "signature",
835
+ "payment",
836
+ "vendor",
837
+ "agent_work",
838
+ "agent_stopped",
839
+ "ci",
840
+ "devbox_job",
841
+ "merge_queue",
842
+ "red_main",
843
+ "deploy",
844
+ "another_work",
845
+ "date",
846
+ "nobody",
847
+ ] as const;
848
+
849
+ /**
850
+ * A property an option names (`dateBinding`, `waits.refFrom`,
851
+ * `waits.dateBinding`): its slug. MIRROR of the pod's `DATE_BINDING`
852
+ * (`utils/lifecycle-model.ts`).
853
+ */
854
+ export const LIFECYCLE_PROPERTY_SLUG = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
855
+
856
+ /**
857
+ * The problems of one option's `dateBinding` and `waits`, in the pod's words
858
+ * and by the pod's rules (`parseLifecycleOptions` / `parseOptionWaits`): a
859
+ * property slug, never on a done/lost option; `waits.on` one of
860
+ * {@link LIFECYCLE_WAITS_ON}, `waitKind` one of {@link LIFECYCLE_WAIT_KINDS},
861
+ * `refFrom` / `dateBinding` property slugs, no other field. Exactly what the
862
+ * pod accepts: nothing more (it does not check that the property exists).
863
+ */
864
+ export function lifecycleOptionWaitsProblems(option: {
865
+ key?: unknown;
866
+ category?: unknown;
867
+ dateBinding?: unknown;
868
+ waits?: unknown;
869
+ }): Array<{ field: "dateBinding" | "waits"; message: string }> {
870
+ const out: Array<{ field: "dateBinding" | "waits"; message: string }> = [];
871
+ const key = String(option.key);
872
+ const finished = option.category === "done" || option.category === "lost";
873
+ if (option.dateBinding !== undefined) {
874
+ if (typeof option.dateBinding !== "string" || !LIFECYCLE_PROPERTY_SLUG.test(option.dateBinding))
875
+ out.push({
876
+ field: "dateBinding",
877
+ message: `option "${key}" dateBinding must name a date property (its slug, e.g. "dueDate")`,
878
+ });
879
+ else if (finished)
880
+ out.push({
881
+ field: "dateBinding",
882
+ message: `option "${key}" is ${String(option.category)}: a finished thing cannot be late (dateBinding is for open, active or paused options)`,
883
+ });
884
+ }
885
+ const w = option.waits;
886
+ if (w === undefined) return out;
887
+ const fail = (message: string) => {
888
+ out.push({ field: "waits", message });
889
+ return out;
890
+ };
891
+ if (!w || typeof w !== "object" || Array.isArray(w))
892
+ return fail(`option "${key}" waits must be an object { on, waitKind?, refFrom?, dateBinding? }`);
893
+ if (finished)
894
+ return fail(
895
+ `option "${key}" is ${String(option.category)}: a finished thing waits on nothing (waits is for open, active or paused options)`
896
+ );
897
+ const waits = w as Record<string, unknown>;
898
+ if (typeof waits.on !== "string" || !(LIFECYCLE_WAITS_ON as readonly string[]).includes(waits.on))
899
+ return fail(`option "${key}" waits.on must be one of ${LIFECYCLE_WAITS_ON.join(" | ")}`);
900
+ if (
901
+ waits.waitKind !== undefined &&
902
+ (typeof waits.waitKind !== "string" || !(LIFECYCLE_WAIT_KINDS as readonly string[]).includes(waits.waitKind))
903
+ )
904
+ return fail(`option "${key}" waits.waitKind is not a wait kind (WAIT_KINDS)`);
905
+ for (const field of ["refFrom", "dateBinding"] as const) {
906
+ const v = waits[field];
907
+ if (v !== undefined && (typeof v !== "string" || !LIFECYCLE_PROPERTY_SLUG.test(v)))
908
+ return fail(
909
+ `option "${key}" waits.${field} must name a property (its slug, e.g. "${field === "refFrom" ? "contactId" : "nextActionAt"}")`
910
+ );
911
+ }
912
+ const unknown = Object.keys(waits).filter(
913
+ (k) => !(LIFECYCLE_OPTION_WAITS_FIELDS as readonly string[]).includes(k)
914
+ );
915
+ if (unknown.length > 0) return fail(`option "${key}" waits has unknown field(s): ${unknown.join(", ")}`);
916
+ return out;
917
+ }
918
+
919
+ /** Key-sorted JSON, so two equal declarations compare equal whatever the YAML key order. */
920
+ function stableJson(v: unknown): string {
921
+ if (Array.isArray(v)) return `[${v.map(stableJson).join(",")}]`;
922
+ if (v && typeof v === "object")
923
+ return `{${Object.keys(v as Record<string, unknown>)
924
+ .filter((k) => (v as Record<string, unknown>)[k] !== undefined)
925
+ .sort()
926
+ .map((k) => `${JSON.stringify(k)}:${stableJson((v as Record<string, unknown>)[k])}`)
927
+ .join(",")}}`;
928
+ return JSON.stringify(v);
929
+ }
930
+
931
+ /**
932
+ * A profile's `lifecycle` must be installable as declared. The pod folds the
933
+ * options onto the property (REPLACING its enum with the option keys) and
934
+ * refuses an uncategorized option, so:
935
+ * - `property` is a select of this profile with `enumValues` (a YAML may omit
936
+ * them: `resolveLifecyclePresets` generates them from the options before
937
+ * this rule runs, so a missing list only reaches here on a non-select);
938
+ * - the option keys ARE those enumValues, same order (an option missing from
939
+ * the list would silently drop a value the select accepts; an extra one
940
+ * would add a value no picker in the YAML shows);
941
+ * - every option has one of the five categories; `humanOnly` is a boolean;
942
+ * `stuckAfterDays` is a whole number of days (1–3650) on an option that
943
+ * can still move (never done/lost — the pod refuses it there too);
944
+ * `dateBinding` and a step's `waits` follow the pod's own rules
945
+ * ({@link lifecycleOptionWaitsProblems}).
946
+ * With a corpus: every template that declares this profile WITH the lifecycle
947
+ * property declares the SAME lifecycle — the pod declares it from whichever
948
+ * template installs first, so two different declarations would make a pod's
949
+ * lifecycle depend on install order. And a playbook binding the kind reads the
950
+ * lifecycle property as its `statusProperty`.
951
+ */
952
+ function checkLifecycleDeclarations(
953
+ tpl: WorkspaceYaml,
954
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
955
+ corpus: WorkspaceYaml[] | undefined,
956
+ add: (rule: string, path: string, message: string) => void
957
+ ): void {
958
+ const categories = new Set<string>(LIFECYCLE_CATEGORIES);
959
+ for (const profile of tpl.profiles ?? []) {
960
+ const lc = profile.lifecycle;
961
+ const at = `profiles.${profile.slug}.lifecycle`;
962
+ if (lc !== undefined) {
963
+ const prop = (profile.properties ?? []).find((p) => p.slug === lc?.property);
964
+ if (!prop || prop.inputType !== "select" || !Array.isArray(prop.enumValues) || prop.enumValues.length === 0) {
965
+ add(
966
+ "lifecycle-property-unknown",
967
+ `${at}.property`,
968
+ `lifecycle property "${String(lc?.property)}" must be a select property of "${profile.slug}" with enumValues`
969
+ );
970
+ } else {
971
+ const options = Array.isArray(lc.options) ? lc.options : [];
972
+ const keys = options.map((o) => o?.key);
973
+ if (stableJson(keys) !== stableJson(prop.enumValues))
974
+ add(
975
+ "lifecycle-options-mismatch",
976
+ `${at}.options`,
977
+ `lifecycle options [${keys.join(", ")}] must be "${profile.slug}.${prop.slug}" enumValues [${prop.enumValues.join(", ")}], same order`
978
+ );
979
+ options.forEach((o, i) => {
980
+ if (!categories.has(o?.category as string))
981
+ add(
982
+ "lifecycle-category",
983
+ `${at}.options[${i}].category`,
984
+ `option "${String(o?.key)}" category "${String(o?.category)}" is not one of ${LIFECYCLE_CATEGORIES.join(" · ")}`
985
+ );
986
+ if (o?.humanOnly !== undefined && typeof o.humanOnly !== "boolean")
987
+ add("lifecycle-option-shape", `${at}.options[${i}].humanOnly`, `option "${String(o?.key)}" humanOnly must be a boolean`);
988
+ const stuck = (o as { stuckAfterDays?: unknown } | undefined)?.stuckAfterDays;
989
+ if (stuck !== undefined) {
990
+ if (typeof stuck !== "number" || !Number.isInteger(stuck) || stuck < 1 || stuck > STUCK_AFTER_DAYS_MAX)
991
+ add(
992
+ "lifecycle-option-shape",
993
+ `${at}.options[${i}].stuckAfterDays`,
994
+ `option "${String(o?.key)}" stuckAfterDays must be a whole number of days from 1 to ${STUCK_AFTER_DAYS_MAX}`
995
+ );
996
+ else if (o?.category === "done" || o?.category === "lost")
997
+ add(
998
+ "lifecycle-option-shape",
999
+ `${at}.options[${i}].stuckAfterDays`,
1000
+ `option "${String(o?.key)}" is ${o.category}: a finished thing cannot be stuck`
1001
+ );
1002
+ }
1003
+ for (const p of lifecycleOptionWaitsProblems(o ?? {}))
1004
+ add("lifecycle-option-shape", `${at}.options[${i}].${p.field}`, p.message);
1005
+ });
1006
+ }
1007
+ }
1008
+
1009
+ if (!corpus) continue;
1010
+ // Every declaration of this profile that carries the lifecycle property
1011
+ // must declare the same lifecycle (absent counts as different).
1012
+ const own = stableJson(profile.lifecycle ?? null);
1013
+ const other = profileDeclarations(profile.slug, tpl, resolveDep, corpus)
1014
+ .filter((p) => p !== profile)
1015
+ .map((p) => p.lifecycle)
1016
+ .find(
1017
+ (l) =>
1018
+ l !== undefined &&
1019
+ stableJson(l) !== own &&
1020
+ (profile.properties ?? []).some((p) => p.slug === l.property)
1021
+ );
1022
+ if (other)
1023
+ add(
1024
+ "lifecycle-diverges",
1025
+ `profiles.${profile.slug}.lifecycle`,
1026
+ `"${profile.slug}" declares "${other.property}" here ${profile.lifecycle ? "with a different lifecycle than" : "without the lifecycle"} another template declares — the pod keeps whichever installs first, so every declaration must be identical`
1027
+ );
1028
+ }
1029
+
1030
+ // A playbook moving a kind with a declared lifecycle moves THAT property.
1031
+ for (const pb of tpl.playbooks ?? []) {
1032
+ const kind = pb.subjectProfile?.profileSlug;
1033
+ const statusProperty = pb.subjectProfile?.statusProperty;
1034
+ if (!kind || statusProperty === undefined) continue;
1035
+ const lc = profileDeclarations(kind, tpl, resolveDep, corpus)
1036
+ .map((p) => p.lifecycle)
1037
+ .find((l) => l !== undefined);
1038
+ if (lc && lc.property !== statusProperty)
1039
+ add(
1040
+ "lifecycle-status-property",
1041
+ `playbooks."${pb.name}".subjectProfile.statusProperty`,
1042
+ `playbook "${pb.name}" moves "${kind}.${statusProperty}" but "${kind}"'s lifecycle is "${lc.property}" — a kind has ONE lifecycle`
1043
+ );
1044
+ }
1045
+ }
1046
+
1047
+ // ── Presentation declarations (entity rendering, decision 8) ────────────────
1048
+
1049
+ /**
1050
+ * A profile's `presentation` must be installable as declared. The pod judges
1051
+ * it with the SAME parser (`parseProfilePresentation`) and stores it as
1052
+ * `uiHints.presentation`, so:
1053
+ * - it parses (family, exactly one media source, field caps);
1054
+ * - every property it names — `media.source` / each `media.fallbacks` entry
1055
+ * (`property`, `entityRef`)
1056
+ * and each `fields.card` / `fields.row` slug — is a property of THIS
1057
+ * declaration (`documentId: true` names the entity's own linked document,
1058
+ * not a property); an `entityRef` property is an `entity_id`;
1059
+ * With a corpus: every declaration of the slug that carries a presentation
1060
+ * declares the SAME one — the pod converges to whichever template applies
1061
+ * last, so two different ones would make a kind's look depend on apply order.
1062
+ * A declaration WITHOUT one is fine: absent leaves the pod's value alone.
1063
+ */
1064
+ function checkPresentationDeclarations(
1065
+ tpl: WorkspaceYaml,
1066
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
1067
+ corpus: WorkspaceYaml[] | undefined,
1068
+ add: (rule: string, path: string, message: string) => void
1069
+ ): void {
1070
+ for (const profile of tpl.profiles ?? []) {
1071
+ if (profile.presentation === undefined) continue;
1072
+ const at = `profiles.${profile.slug}.presentation`;
1073
+ const parsed = parseProfilePresentation(profile.presentation);
1074
+ if (!parsed.ok) {
1075
+ for (const issue of parsed.issues)
1076
+ add(
1077
+ "presentation-invalid",
1078
+ `profiles.${profile.slug}.${issue.path}`,
1079
+ `"${profile.slug}" ${issue.path}: ${issue.message}`
1080
+ );
1081
+ continue;
1082
+ }
1083
+ const props = new Map((profile.properties ?? []).map((p) => [p.slug, p]));
1084
+ const named: Array<{ path: string; slug: string; entityRef?: boolean }> = [];
1085
+ // `source` then each fallback: every home of the media must exist here.
1086
+ declaredMediaSources(parsed.value).forEach((source, i) => {
1087
+ const where = i === 0 ? `${at}.media.source` : `${at}.media.fallbacks[${i - 1}]`;
1088
+ if ("property" in source) named.push({ path: `${where}.property`, slug: source.property });
1089
+ if ("entityRef" in source)
1090
+ named.push({ path: `${where}.entityRef`, slug: source.entityRef, entityRef: true });
1091
+ });
1092
+ for (const alt of ["card", "row"] as const)
1093
+ (parsed.value.fields?.[alt] ?? []).forEach((slug, i) =>
1094
+ named.push({ path: `${at}.fields.${alt}[${i}]`, slug })
1095
+ );
1096
+ for (const n of named) {
1097
+ const prop = props.get(n.slug);
1098
+ if (!prop)
1099
+ add(
1100
+ "presentation-property-unknown",
1101
+ n.path,
1102
+ `"${profile.slug}" presentation names "${n.slug}", which is not a property of "${profile.slug}" here`
1103
+ );
1104
+ else if (n.entityRef && prop.valueType !== "entity_id")
1105
+ add(
1106
+ "presentation-entityref-type",
1107
+ n.path,
1108
+ `"${profile.slug}" presentation entityRef "${n.slug}" must be an entity_id property (it is ${prop.valueType})`
1109
+ );
1110
+ }
1111
+
1112
+ if (!corpus) continue;
1113
+ const own = stableJson(parsed.value);
1114
+ const other = profileDeclarations(profile.slug, tpl, resolveDep, corpus)
1115
+ .filter((p) => p !== profile && p.presentation !== undefined)
1116
+ .find((p) => {
1117
+ const o = parseProfilePresentation(p.presentation);
1118
+ return !o.ok || stableJson(o.value) !== own;
1119
+ });
1120
+ if (other)
1121
+ add(
1122
+ "presentation-diverges",
1123
+ at,
1124
+ `"${profile.slug}" declares a different presentation than another template does — the pod keeps whichever applies last, so every declaration must be identical (or omit it)`
1125
+ );
1126
+ }
1127
+ }
1128
+
1129
+ // ── Intent: what a space produces (object-first homes H0) ───────────────────
1130
+
1131
+ /**
1132
+ * `meta.intent = { label, produces: [{ profile }] }` names what the space
1133
+ * PRODUCES, in order; no `produces` makes it a library. Rules:
1134
+ * - `label` is a non-empty string of at most {@link INTENT_LABEL_MAX} chars;
1135
+ * - each `profile` is declared by this template or by a template in its
1136
+ * dependency closure (a role/kind it depends on), and is named once;
1137
+ * - each produced profile DECLARES a lifecycle (in one of those
1138
+ * declarations) — a home counts things by lifecycle category, and a kind
1139
+ * without one cannot be counted;
1140
+ * - only a template that OWNS a space declares it (never an overlay or a
1141
+ * pack — `ownsNoWorkspace`).
1142
+ * The dependency half needs the closure: a profile neither own nor reachable
1143
+ * because a dependency did not resolve is not judged (no corpus to see it).
1144
+ */
1145
+ function checkIntent(
1146
+ tpl: WorkspaceYaml,
1147
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
1148
+ add: (rule: string, path: string, message: string) => void
1149
+ ): void {
1150
+ const intent = tpl.meta?.intent as unknown;
1151
+ if (intent === undefined) return;
1152
+ // An overlay layers onto its base's space and a pack owns none: an intent
1153
+ // there would overwrite the base space's (the pod merges it into the space
1154
+ // it lands on) or land nowhere. The space's own template names it.
1155
+ const why = ownsNoWorkspace(tpl);
1156
+ if (why) {
1157
+ add(
1158
+ "intent-no-workspace",
1159
+ "meta.intent",
1160
+ `"${tpl.meta.slug}" is ${why} — it owns no space, so it cannot name what a space produces; the template that owns the space declares meta.intent`
1161
+ );
1162
+ return;
1163
+ }
1164
+ if (!intent || typeof intent !== "object" || Array.isArray(intent)) {
1165
+ add("intent-shape", "meta.intent", "meta.intent must be { label, produces?: [{ profile }] }");
1166
+ return;
1167
+ }
1168
+ const { label, produces } = intent as { label?: unknown; produces?: unknown };
1169
+ if (typeof label !== "string" || !label.trim() || label.trim().length > INTENT_LABEL_MAX)
1170
+ add(
1171
+ "intent-label",
1172
+ "meta.intent.label",
1173
+ `meta.intent.label must be a non-empty string of at most ${INTENT_LABEL_MAX} characters (got ${JSON.stringify(label)})`
1174
+ );
1175
+ if (produces === undefined) return;
1176
+ if (!Array.isArray(produces)) {
1177
+ add("intent-shape", "meta.intent.produces", "meta.intent.produces must be a list of { profile }");
1178
+ return;
1179
+ }
1180
+ const closure = [...collectDependencyClosure(tpl, resolveDep)];
1181
+ const sources = [tpl, ...closure.map(resolveDep).filter((t): t is WorkspaceYaml => !!t)];
1182
+ const unresolved = closure.some((d) => !resolveDep(d));
1183
+ const seen = new Set<string>();
1184
+ produces.forEach((entry, i) => {
1185
+ const at = `meta.intent.produces[${i}]`;
1186
+ const slug = (entry as { profile?: unknown } | null)?.profile;
1187
+ if (typeof slug !== "string" || !slug) {
1188
+ add("intent-shape", `${at}.profile`, `${at} must be { profile: <profile slug> }`);
1189
+ return;
1190
+ }
1191
+ if (seen.has(slug)) add("intent-duplicate", `${at}.profile`, `"${slug}" is produced twice`);
1192
+ seen.add(slug);
1193
+ const decls = sources.flatMap((t) => (t.profiles ?? []).filter((p) => p.slug === slug));
1194
+ if (decls.length === 0) {
1195
+ if (!unresolved)
1196
+ add(
1197
+ "intent-profile-unknown",
1198
+ `${at}.profile`,
1199
+ `"${slug}" is produced but neither "${tpl.meta.slug}" nor a template it depends on declares it`
1200
+ );
1201
+ return;
1202
+ }
1203
+ if (!decls.some((p) => p.lifecycle !== undefined))
1204
+ add(
1205
+ "intent-no-lifecycle",
1206
+ `${at}.profile`,
1207
+ `"${slug}" is produced but declares no lifecycle — a space home counts what it produces by lifecycle; declare "${slug}"'s lifecycle or leave it out of produces`
1208
+ );
1209
+ });
1210
+ }
1211
+
1212
+ // ── Start: a space offered as an onboarding start ───────────────────────────
1213
+
1214
+ /**
1215
+ * `meta.start` (see `TemplateStart`). Rules, all `start-*`:
1216
+ * - only a template that OWNS a space declares one (`start-no-workspace`);
1217
+ * - the shape is complete (`start-shape`); label ≤ {@link INTENT_LABEL_MAX}
1218
+ * (`start-label`); example and starters sizes (`start-example-count`,
1219
+ * `start-starters-count`);
1220
+ * - `views` are 1-{@link START_VIEWS_MAX} slugs of THIS template's views,
1221
+ * each scoped to `profile` (`start-views-count`, `start-views-exist`,
1222
+ * `start-views-scope`);
1223
+ * - `profile` is the template's own kind or one from its dependency closure
1224
+ * (`start-profile-unknown`);
1225
+ * - `example` keys are properties of `profile` and select values are among its
1226
+ * `enumValues` (`start-example-property`, `start-example-enum`);
1227
+ * - with a corpus: `order` and every `suggestFor` id belong to ONE start
1228
+ * (`start-order-duplicate`, `start-suggest-duplicate`).
1229
+ * The profile half needs the closure: an unresolved dependency is not judged.
1230
+ */
1231
+ function checkStart(
1232
+ tpl: WorkspaceYaml,
1233
+ resolveDep: (slug: string) => WorkspaceYaml | undefined,
1234
+ corpus: WorkspaceYaml[] | undefined,
1235
+ add: (rule: string, path: string, message: string) => void
1236
+ ): void {
1237
+ const raw = tpl.meta?.start as unknown;
1238
+ if (raw === undefined) return;
1239
+ const why = ownsNoWorkspace(tpl);
1240
+ if (why) {
1241
+ add("start-no-workspace", "meta.start", `"${tpl.meta.slug}" is ${why} — it owns no space, so it cannot be an onboarding start`);
1242
+ return;
1243
+ }
1244
+ const isStr = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
1245
+ const strList = (v: unknown): v is string[] => Array.isArray(v) && v.every(isStr);
1246
+ const st = raw as Record<string, any>;
1247
+ const fs = st?.firstSession;
1248
+ const shapeOk =
1249
+ !!st && typeof st === "object" && !Array.isArray(st) &&
1250
+ Number.isInteger(st.order) && st.order > 0 && isStr(st.label) && isStr(st.promise) &&
1251
+ strList(st.suggestFor) && isStr(st.profile) && strList(st.views) && strList(st.starters) &&
1252
+ Array.isArray(st.example) && st.example.every((e: any) => e && isStr(e.title) && (e.values === undefined || (typeof e.values === "object" && e.values !== null && !Array.isArray(e.values)))) &&
1253
+ !!fs && isStr(fs.title) && isStr(fs.goal);
1254
+ if (!shapeOk) {
1255
+ add("start-shape", "meta.start", "meta.start must be { order (int ≥ 1), label, promise, suggestFor[], profile, views[], example[{ title, values? }], firstSession{ title, goal }, starters[] }");
1256
+ return;
1257
+ }
1258
+ const start = st as import("./types.ts").TemplateStart;
1259
+ if (start.label.trim().length > INTENT_LABEL_MAX)
1260
+ add("start-label", "meta.start.label", `meta.start.label must be at most ${INTENT_LABEL_MAX} characters (got ${JSON.stringify(start.label)})`);
1261
+ const [exMin, exMax] = START_EXAMPLE_RANGE;
1262
+ if (start.example.length < exMin || start.example.length > exMax)
1263
+ add("start-example-count", "meta.start.example", `meta.start.example needs ${exMin}-${exMax} rows (got ${start.example.length})`);
1264
+ const [stMin, stMax] = START_STARTERS_RANGE;
1265
+ if (start.starters.length < stMin || start.starters.length > stMax)
1266
+ add("start-starters-count", "meta.start.starters", `meta.start.starters needs ${stMin}-${stMax} chips (got ${start.starters.length})`);
1267
+
1268
+ // Views: this template's own, scoped to the produced kind.
1269
+ if (start.views.length < 1 || start.views.length > START_VIEWS_MAX)
1270
+ add("start-views-count", "meta.start.views", `meta.start.views needs 1-${START_VIEWS_MAX} view slugs (got ${start.views.length})`);
1271
+ start.views.forEach((slug, i) => {
1272
+ const at = `meta.start.views[${i}]`;
1273
+ const view = (tpl.views ?? []).find((v) => v.slug === slug);
1274
+ if (!view) {
1275
+ add("start-views-exist", at, `"${slug}" is not a view (by slug) of "${tpl.meta.slug}"`);
1276
+ return;
1277
+ }
1278
+ const scope = [view.scopeProfileSlug, ...(view.scopeProfileSlugs ?? [])].filter(Boolean);
1279
+ if (!scope.includes(start.profile))
1280
+ add("start-views-scope", at, `view "${slug}" is not scoped to "${start.profile}" (scope: ${scope.join(", ") || "none"})`);
1281
+ });
1282
+
1283
+ // Profile + example against the declarations in the closure.
1284
+ const closure = [...collectDependencyClosure(tpl, resolveDep)];
1285
+ const sources = [tpl, ...closure.map(resolveDep).filter((t): t is WorkspaceYaml => !!t)];
1286
+ const decls = sources.flatMap((t) => (t.profiles ?? []).filter((p) => p.slug === start.profile));
1287
+ if (decls.length === 0) {
1288
+ if (!closure.some((d) => !resolveDep(d)))
1289
+ add("start-profile-unknown", "meta.start.profile", `"${start.profile}" is neither declared by "${tpl.meta.slug}" nor by a template it depends on`);
1290
+ } else {
1291
+ const props = new Map(decls.flatMap((p) => p.properties ?? []).map((p) => [p.slug, p] as const));
1292
+ start.example.forEach((row, i) => {
1293
+ for (const [key, value] of Object.entries(row.values ?? {})) {
1294
+ const at = `meta.start.example[${i}].values.${key}`;
1295
+ const prop = props.get(key);
1296
+ if (!prop) add("start-example-property", at, `"${key}" is not a property of "${start.profile}"`);
1297
+ else if (prop.enumValues?.length && !prop.enumValues.includes(value as string))
1298
+ add("start-example-enum", at, `${JSON.stringify(value)} is not one of ${start.profile}.${key}: ${prop.enumValues.join(", ")}`);
1299
+ }
1300
+ });
1301
+ }
1302
+
1303
+ // Catalog-wide: one start per card slot and per identity.
1304
+ const ids = new Set<string>();
1305
+ start.suggestFor.forEach((id, i) => {
1306
+ if (ids.has(id)) add("start-suggest-duplicate", `meta.start.suggestFor[${i}]`, `identity "${id}" is listed twice`);
1307
+ ids.add(id);
1308
+ });
1309
+ for (const other of corpus ?? []) {
1310
+ const o = other.meta?.start;
1311
+ if (!o || other.meta.slug === tpl.meta.slug) continue;
1312
+ if (o.order === start.order)
1313
+ add("start-order-duplicate", "meta.start.order", `order ${start.order} is also used by "${other.meta.slug}"`);
1314
+ for (const id of o.suggestFor ?? [])
1315
+ if (ids.has(id)) add("start-suggest-duplicate", "meta.start.suggestFor", `identity "${id}" is also suggested by "${other.meta.slug}"`);
1316
+ }
1317
+ }
1318
+
1319
+ /** True when the resolver is backed by a real corpus (it resolves foundation). */
1320
+ function hasCorpus(resolveDep: (slug: string) => WorkspaceYaml | undefined): boolean {
1321
+ return resolveDep(SHARED_ROLE_SSOT_SLUG) !== undefined;
1322
+ }
1323
+
275
1324
  // ── The validator ────────────────────────────────────────────────────────────
276
1325
 
277
1326
  /**
@@ -284,13 +1333,21 @@ function resolveDepFrom(
284
1333
  * when `opts.converters` is supplied).
285
1334
  */
286
1335
  export function validateTemplate(
287
- tpl: WorkspaceYaml,
288
- opts: ValidateOptions = {}
1336
+ authored: WorkspaceYaml,
1337
+ authoredOpts: ValidateOptions = {}
289
1338
  ): ValidateResult {
290
1339
  const errors: ValidationError[] = [];
291
1340
  const add = (rule: string, path: string, message: string) =>
292
1341
  errors.push({ rule, path, message });
293
1342
 
1343
+ // ── Lifecycle presets ──────────────────────────────────────────────────────
1344
+ // Compile `adopts:` overlays and generate omitted lifecycle `enumValues`
1345
+ // (lifecycle-presets.ts) before any rule reads them — in this template and in
1346
+ // every corpus / dependency template it is compared with.
1347
+ const { template: tpl, problems } = resolveLifecyclePresets(authored);
1348
+ for (const p of problems) add("lifecycle-adopts", p.path, p.message);
1349
+ const opts = withResolvedLifecycles(authoredOpts, authored, tpl);
1350
+
294
1351
  // ── Required fields ────────────────────────────────────────────────────────
295
1352
  if (!tpl.meta) add("meta-required", "meta", "Missing required field: meta");
296
1353
  else {
@@ -311,6 +1368,33 @@ export function validateTemplate(
311
1368
  );
312
1369
  }
313
1370
  }
1371
+ // ── taskIntents ───────────────────────────────────────────────────────────
1372
+ // A template's DECLARED task requirements, each of which must be a slug of the
1373
+ // closed vocabulary. This is the ONE runtime import in this file (the values
1374
+ // cannot be type-only): it is what keeps `taskIntents` from becoming a second
1375
+ // hand-written intent list. The vocabulary is the pod's `capability_intents`
1376
+ // table, mirrored into `@synap-core/types/capability-intents` because the pod
1377
+ // cannot import that package — a parity guard re-derives the pod's seed
1378
+ // migrations there, so a slug added to the pod without a mirror update fails
1379
+ // THAT test rather than silently making every template below invalid.
1380
+ if (tpl.taskIntents !== undefined) {
1381
+ if (!Array.isArray(tpl.taskIntents)) {
1382
+ add(
1383
+ "task-intents-not-an-array",
1384
+ "taskIntents",
1385
+ "taskIntents must be an array of intent slugs"
1386
+ );
1387
+ } else {
1388
+ const unknown = unknownIntents(tpl.taskIntents);
1389
+ for (const slug of unknown) {
1390
+ add(
1391
+ "task-intent-unknown",
1392
+ `taskIntents[${tpl.taskIntents.indexOf(slug)}]`,
1393
+ `"${slug}" is not a known intent. The closed vocabulary is: ${CAPABILITY_INTENTS.join(", ")}.`
1394
+ );
1395
+ }
1396
+ }
1397
+ }
314
1398
  // A template earns validity by declaring a data-schema profile OR by carrying
315
1399
  // operational defaults (relation vocabulary / commands / automations). The
316
1400
  // `base` template is the latter: pod-wide operational config with no
@@ -323,9 +1407,20 @@ export function validateTemplate(
323
1407
  (tpl.relationDefs && tpl.relationDefs.length > 0) ||
324
1408
  (tpl.commands && tpl.commands.length > 0) ||
325
1409
  (tpl.automations && tpl.automations.length > 0);
1410
+ // A PACK (tag `suite`) is never a workspace of its own (founder decision D8,
1411
+ // 2026-09-25): install layers it onto its primary required domain, so it
1412
+ // needs no profile shell — it earns validity by requiring ≥1 workspace.
1413
+ const isPackRequiringDomains =
1414
+ (tpl.meta?.tags ?? []).includes('suite') &&
1415
+ (tpl.dependencies ?? []).some(
1416
+ (d) =>
1417
+ (d.kind ?? 'workspace') === 'workspace' &&
1418
+ (d.relation ?? 'require') === 'require'
1419
+ );
326
1420
  if (
327
1421
  (!tpl.profiles || tpl.profiles.length === 0) &&
328
- !carriesOperationalDefaults
1422
+ !carriesOperationalDefaults &&
1423
+ !isPackRequiringDomains
329
1424
  ) {
330
1425
  add(
331
1426
  "profiles-required",
@@ -406,6 +1501,49 @@ export function validateTemplate(
406
1501
  }
407
1502
  }
408
1503
 
1504
+ // ── Space package: the space's own code, and the cell that opens it ───────
1505
+ // A package cell installs as `cell:<meta.slug>:<key>` (the pod's
1506
+ // `packageCellTypeKey`). A space whose entry names a namespaced cell its own
1507
+ // template does not build would open "Unknown cell" on every install.
1508
+ const packageCellKeys = new Set<string>();
1509
+ for (const [i, cell] of (tpl.spacePackage?.cells ?? []).entries()) {
1510
+ const at = `spacePackage.cells[${i}]`;
1511
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(cell.key ?? "")) {
1512
+ add("space-package-cell-key", `${at}.key`, "A space package cell needs a kebab-case key");
1513
+ } else if (packageCellKeys.has(cell.key)) {
1514
+ add("space-package-cell-key-duplicate", `${at}.key`, `Space package cell key "${cell.key}" is declared twice`);
1515
+ } else {
1516
+ packageCellKeys.add(cell.key);
1517
+ }
1518
+ const source = cell.source ?? "";
1519
+ if (
1520
+ !source.startsWith("packages/web/src/space-packages/") ||
1521
+ source.split("/").includes("..") ||
1522
+ !/\.tsx?$/.test(source)
1523
+ ) {
1524
+ add(
1525
+ "space-package-cell-source",
1526
+ `${at}.source`,
1527
+ "A space package cell is built from the shared components: its source is a .ts/.tsx file under packages/web/src/space-packages/"
1528
+ );
1529
+ }
1530
+ }
1531
+ const entryCellKeys: Array<[string, string | undefined]> = [
1532
+ ["app.cellKey", tpl.app?.kind === "stored" ? tpl.app.cellKey : undefined],
1533
+ ["layout.primarySurface.cellKey", authoredPrimary?.kind === "cell" ? authoredPrimary.cellKey : undefined],
1534
+ ];
1535
+ for (const [path, cellKey] of entryCellKeys) {
1536
+ if (!cellKey?.startsWith("cell:")) continue;
1537
+ const own = `cell:${tpl.meta?.slug}:`;
1538
+ if (!cellKey.startsWith(own) || !packageCellKeys.has(cellKey.slice(own.length))) {
1539
+ add(
1540
+ "space-package-entry-unknown",
1541
+ path,
1542
+ `"${cellKey}" is not a cell this template installs: declare it in spacePackage.cells (installed as ${own}<key>)`
1543
+ );
1544
+ }
1545
+ }
1546
+
409
1547
  // ── Referential integrity: build the known-profile predicate ──────────────
410
1548
  const baseProfileSlugs = collectBaseProfileSlugs(tpl, resolveDepFrom(opts));
411
1549
  const slugs = new Set((tpl.profiles || []).map((p) => p.slug));
@@ -426,6 +1564,18 @@ export function validateTemplate(
426
1564
  );
427
1565
  }
428
1566
 
1567
+ // ── Reserved slugs ──────────────────────────────────────────────────────
1568
+ // The pod refuses these at every profile write path; a reference to one then
1569
+ // fails referential integrity below, so refusing the declaration is enough.
1570
+ for (const profile of tpl.profiles || []) {
1571
+ if (!RESERVED_PROFILE_SLUGS.has(String(profile.slug).trim().toLowerCase())) continue;
1572
+ add(
1573
+ "profile-slug-reserved",
1574
+ `profiles.${profile.slug}`,
1575
+ `Profile slug "${profile.slug}" is reserved: projects live in the pod's projects table, not as an entity kind. Drop the profile; agents create projects through the project door (synap_create_project) and file entities into one with file_into_project (the belongs_to_project relation).`
1576
+ );
1577
+ }
1578
+
429
1579
  // Views: structured views need a scope; scoped profiles must resolve.
430
1580
  for (const v of tpl.views || []) {
431
1581
  const type = String(v.type || "").toLowerCase();
@@ -489,11 +1639,34 @@ export function validateTemplate(
489
1639
  `entityLink ${el.sourceProfileSlug}→${el.targetProfileSlug} is missing its \`type\` (the predicate)`
490
1640
  );
491
1641
  }
1642
+ // `required` is a boolean or absent. The pod's reconcile marks a link
1643
+ // required on any truthy value and clears it on `false`
1644
+ // (`withRequiredLinkMark`, synap-backend), so `"yes"` or `1` would reach a
1645
+ // pod as a rule nobody wrote as one.
1646
+ const required = (el as { required?: unknown }).required;
1647
+ if (required !== undefined && typeof required !== "boolean") {
1648
+ add(
1649
+ "entitylink-required-not-boolean",
1650
+ "entityLinks",
1651
+ `entityLink ${el.sourceProfileSlug}→${el.targetProfileSlug} (${String(el.type)}) has \`required: ${JSON.stringify(required)}\`; write true or false`
1652
+ );
1653
+ }
492
1654
  }
493
1655
 
494
1656
  const onboarding = tpl.onboarding;
495
1657
  if (onboarding) {
496
- if (typeof onboarding.goal !== "string" || onboarding.goal.trim() === "") {
1658
+ // The brief has two modes. The INTERVIEW (goal / openingQuestions /
1659
+ // doneWhen) needs a persona to run it and something to collect; a
1660
+ // steady-state brief (purpose / anchors / fetch) needs neither — a space
1661
+ // can tell agents how to work in it without ever interviewing anyone.
1662
+ const interview =
1663
+ onboarding.goal !== undefined ||
1664
+ onboarding.openingQuestions !== undefined ||
1665
+ onboarding.doneWhen !== undefined;
1666
+ if (
1667
+ interview &&
1668
+ (typeof onboarding.goal !== "string" || onboarding.goal.trim() === "")
1669
+ ) {
497
1670
  add(
498
1671
  "onboarding-goal-empty",
499
1672
  "onboarding.goal",
@@ -501,8 +1674,9 @@ export function validateTemplate(
501
1674
  );
502
1675
  }
503
1676
  if (
504
- typeof onboarding.framing !== "string" ||
505
- onboarding.framing.trim() === ""
1677
+ (interview || onboarding.framing !== undefined) &&
1678
+ (typeof onboarding.framing !== "string" ||
1679
+ onboarding.framing.trim() === "")
506
1680
  ) {
507
1681
  add(
508
1682
  "onboarding-framing-empty",
@@ -510,13 +1684,106 @@ export function validateTemplate(
510
1684
  "onboarding.framing must be a non-empty string"
511
1685
  );
512
1686
  }
513
- if (!Array.isArray(onboarding.collect) || onboarding.collect.length === 0) {
1687
+ if (
1688
+ (interview || onboarding.collect !== undefined) &&
1689
+ (!Array.isArray(onboarding.collect) || onboarding.collect.length === 0)
1690
+ ) {
514
1691
  add(
515
1692
  "onboarding-collect-empty",
516
1693
  "onboarding.collect",
517
1694
  "onboarding.collect must contain at least one structured target"
518
1695
  );
519
1696
  }
1697
+ if (
1698
+ onboarding.purpose !== undefined &&
1699
+ (typeof onboarding.purpose !== "string" ||
1700
+ onboarding.purpose.trim() === "")
1701
+ ) {
1702
+ add(
1703
+ "onboarding-purpose-empty",
1704
+ "onboarding.purpose",
1705
+ "onboarding.purpose must be a non-empty string"
1706
+ );
1707
+ }
1708
+ if (!interview && !onboarding.purpose) {
1709
+ add(
1710
+ "onboarding-brief-empty",
1711
+ "onboarding",
1712
+ "a brief without an interview (goal) must state its purpose"
1713
+ );
1714
+ }
1715
+
1716
+ const seedTitles = new Map<string, number>();
1717
+ for (const e of tpl.seedEntities || []) {
1718
+ seedTitles.set(e.title, (seedTitles.get(e.title) ?? 0) + 1);
1719
+ }
1720
+ const seedAnswersTo = (ref: string) =>
1721
+ (tpl.seedEntities || []).some(
1722
+ (e) =>
1723
+ `${e.profileSlug}:${e.title}` === ref ||
1724
+ (e.title === ref && seedTitles.get(ref) === 1)
1725
+ );
1726
+ let roots = 0;
1727
+ for (const [i, a] of (onboarding.anchors || []).entries()) {
1728
+ const where = `onboarding.anchors.${i}`;
1729
+ if (!a || typeof a.profileSlug !== "string" || !known(a.profileSlug)) {
1730
+ add(
1731
+ "onboarding-anchor-invalid",
1732
+ where,
1733
+ `anchor references unknown profile "${String(a?.profileSlug)}"`
1734
+ );
1735
+ continue;
1736
+ }
1737
+ if (a.role !== "root" && a.role !== "context") {
1738
+ add(
1739
+ "onboarding-anchor-invalid",
1740
+ `${where}.role`,
1741
+ `anchor role must be "root" or "context"`
1742
+ );
1743
+ }
1744
+ if (a.role === "root") roots++;
1745
+ if (a.seedRef !== undefined && !seedAnswersTo(a.seedRef)) {
1746
+ add(
1747
+ "onboarding-anchor-seedref-unknown",
1748
+ `${where}.seedRef`,
1749
+ `anchor seedRef "${a.seedRef}" names no seed entity (use "kind:Title" or a unique title)`
1750
+ );
1751
+ }
1752
+ if (
1753
+ a.limit !== undefined &&
1754
+ !(Number.isInteger(a.limit) && a.limit > 0)
1755
+ ) {
1756
+ add(
1757
+ "onboarding-anchor-invalid",
1758
+ `${where}.limit`,
1759
+ "anchor limit must be a positive integer"
1760
+ );
1761
+ }
1762
+ }
1763
+ if (roots > 1) {
1764
+ add(
1765
+ "onboarding-anchor-invalid",
1766
+ "onboarding.anchors",
1767
+ "a brief has at most one root anchor"
1768
+ );
1769
+ }
1770
+ for (const [i, f] of (onboarding.fetch || []).entries()) {
1771
+ const hasKind = typeof f?.profileSlug === "string" && f.profileSlug;
1772
+ const hasQuery = typeof f?.query === "string" && f.query.trim();
1773
+ if (!hasKind && !hasQuery) {
1774
+ add(
1775
+ "onboarding-fetch-invalid",
1776
+ `onboarding.fetch.${i}`,
1777
+ "a fetch hint needs a profileSlug or a query"
1778
+ );
1779
+ } else if (hasKind && !known(f.profileSlug!)) {
1780
+ add(
1781
+ "onboarding-fetch-invalid",
1782
+ `onboarding.fetch.${i}.profileSlug`,
1783
+ `fetch hint references unknown profile "${f.profileSlug}"`
1784
+ );
1785
+ }
1786
+ }
520
1787
 
521
1788
  const expertise = onboarding.expertise;
522
1789
  for (const [field, values] of [
@@ -595,6 +1862,13 @@ export function validateTemplate(
595
1862
  `onboarding.collect target "${c.profileSlug}" keyFields must contain only non-empty strings`
596
1863
  );
597
1864
  }
1865
+ if (c.min !== undefined && !(Number.isInteger(c.min) && c.min > 0)) {
1866
+ add(
1867
+ "onboarding-collect-min-invalid",
1868
+ `onboarding.collect.${c.profileSlug}.min`,
1869
+ `onboarding.collect target "${c.profileSlug}" min must be a positive integer`
1870
+ );
1871
+ }
598
1872
  if (!known(c.profileSlug))
599
1873
  add(
600
1874
  "onboarding-ref-unknown",
@@ -602,6 +1876,75 @@ export function validateTemplate(
602
1876
  `onboarding.collect references unknown profile "${c.profileSlug}"`
603
1877
  );
604
1878
  }
1879
+
1880
+ // Template rules — each becomes a rule row in the space; `key` is its
1881
+ // identity across template versions, so it must be present and unique.
1882
+ const ruleKeys = new Set<string>();
1883
+ for (const [i, r] of (tpl.rules || []).entries()) {
1884
+ const where = `rules.${i}`;
1885
+ if (typeof r?.key !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(r.key)) {
1886
+ add(
1887
+ "rule-key-invalid",
1888
+ `${where}.key`,
1889
+ "a template rule needs a kebab-case `key` (its identity across versions)"
1890
+ );
1891
+ } else if (ruleKeys.has(r.key)) {
1892
+ add("rule-key-duplicate", `${where}.key`, `duplicate rule key "${r.key}"`);
1893
+ } else {
1894
+ ruleKeys.add(r.key);
1895
+ }
1896
+ if (typeof r?.intent !== "string" || r.intent.trim() === "") {
1897
+ add(
1898
+ "rule-intent-empty",
1899
+ `${where}.intent`,
1900
+ "a template rule needs a non-empty `intent`"
1901
+ );
1902
+ }
1903
+ }
1904
+
1905
+ // Declared skills — each is LINKED to the space at install, so a malformed
1906
+ // one installs a space that cannot load the skill it was told to trust. The
1907
+ // SHAPE rule is shared with the pod's applier (`validateSpaceSkillDeclaration`)
1908
+ // so author time and install time cannot disagree. A duplicate slug is
1909
+ // rejected: two modes for one skill is an ambiguous declaration, not a merge.
1910
+ const skillSlugs = new Set<string>();
1911
+ for (const [i, s] of (tpl.skills || []).entries()) {
1912
+ const where = `skills.${i}`;
1913
+ const reason = validateSpaceSkillDeclaration(s);
1914
+ if (reason) {
1915
+ add("skill-invalid", where, reason);
1916
+ continue;
1917
+ }
1918
+ if (skillSlugs.has(s.slug)) {
1919
+ add("skill-duplicate", `${where}.slug`, `duplicate skill slug "${s.slug}"`);
1920
+ } else {
1921
+ skillSlugs.add(s.slug);
1922
+ }
1923
+ }
1924
+
1925
+ // ── A LINK needs an INSTALL ──────────────────────────────────────────────
1926
+ // `skills:` names what this space ACTIVATES; `dependencies[]` is what the pod
1927
+ // INSTALLS. A linked skill that no dependency brings — and that is not in the
1928
+ // seeded `system/` namespace, which every pod already has — installs cleanly
1929
+ // and activates NOTHING: a missing dependency is non-fatal by design (the
1930
+ // resolver reports `required-absent` and carries on), so the only symptom is
1931
+ // an agent that never gets the skill. That silence is exactly the failure this
1932
+ // rule makes LOUD, at author time, on every door (build, CLI validate, publish).
1933
+ const installedSkillSlugs = new Set(
1934
+ (tpl.dependencies ?? [])
1935
+ .filter((d) => (d.kind ?? "workspace") === "skill")
1936
+ .map((d) => d.slug)
1937
+ );
1938
+ for (const [i, s] of (tpl.skills || []).entries()) {
1939
+ // `system/…` is the seeded namespace — present on every pod, no install needed.
1940
+ if (s.slug.startsWith("system/")) continue;
1941
+ if (installedSkillSlugs.has(s.slug)) continue;
1942
+ add(
1943
+ "skill-linked-without-install",
1944
+ `skills.${i}.slug`,
1945
+ `"${s.slug}" is LINKED but nothing INSTALLS it — add \`dependencies: [{ slug: ${s.slug}, kind: skill, relation: require }]\` (and publish that skill package), or reference the seeded \`system/…\` namespace instead. As written the install succeeds and the link does NOTHING.`
1946
+ );
1947
+ }
605
1948
  for (const s of tpl.seedEntities || []) {
606
1949
  if (!known(s.profileSlug))
607
1950
  add(
@@ -696,6 +2039,62 @@ export function validateTemplate(
696
2039
  );
697
2040
  }
698
2041
  }
2042
+
2043
+ // ── Template documents (`documents[]`) ─────────────────────────────────────
2044
+ // The pod knows a template document by its slug in the space (it never
2045
+ // rewrites or recreates one), so a slug must be stable, unique and named by
2046
+ // every document sidebar item. A compose overlay installs into its base's
2047
+ // space, so it may also name a document its base carries.
2048
+ const documentSlugs = new Set<string>();
2049
+ for (const [i, d] of (tpl.documents || []).entries()) {
2050
+ const where = `documents[${i}]`;
2051
+ if (typeof d?.slug !== "string" || !TEMPLATE_DOCUMENT_SLUG_RE.test(d.slug)) {
2052
+ add(
2053
+ "document-slug-invalid",
2054
+ `${where}.slug`,
2055
+ `${where}.slug "${d?.slug ?? ""}" must be kebab-case (a-z, 0-9, -): the space knows the document by it`
2056
+ );
2057
+ continue;
2058
+ }
2059
+ if (documentSlugs.has(d.slug))
2060
+ add(
2061
+ "document-slug-duplicate",
2062
+ `${where}.slug`,
2063
+ `document slug "${d.slug}" is declared twice`
2064
+ );
2065
+ documentSlugs.add(d.slug);
2066
+ if (typeof d.title !== "string" || !d.title.trim())
2067
+ add(
2068
+ "document-title-required",
2069
+ `${where}.title`,
2070
+ `document "${d.slug}" needs a title (its name)`
2071
+ );
2072
+ if (typeof d.content !== "string")
2073
+ add(
2074
+ "document-content-required",
2075
+ `${where}.content`,
2076
+ `document "${d.slug}" needs a markdown content (it may be empty)`
2077
+ );
2078
+ if (d.scope !== undefined && d.scope !== "space" && d.scope !== "pod")
2079
+ add(
2080
+ "document-scope-unknown",
2081
+ `${where}.scope`,
2082
+ `document "${d.slug}" scope "${String(d.scope)}" is not one of: space, pod`
2083
+ );
2084
+ }
2085
+ const reachableDocumentSlugs = new Set([
2086
+ ...documentSlugs,
2087
+ ...composeBaseDocumentSlugs(tpl, resolveDepFrom(opts)),
2088
+ ]);
2089
+ for (const [i, si] of (tpl.layout?.sidebarItems || []).entries()) {
2090
+ if (si.kind !== "document") continue;
2091
+ if (!si.documentSlug || !reachableDocumentSlugs.has(si.documentSlug))
2092
+ add(
2093
+ "sidebar-document-unknown",
2094
+ `layout.sidebarItems[${i}]`,
2095
+ `layout.sidebarItems ("${si.label ?? si.documentSlug ?? "document"}") names document "${si.documentSlug ?? ""}", which this template (or a base it composes onto) does not declare in documents[]`
2096
+ );
2097
+ }
699
2098
  for (const dt of tpl.displayTemplates || []) {
700
2099
  if (dt.entityType && !known(dt.entityType)) {
701
2100
  add(
@@ -714,6 +2113,16 @@ export function validateTemplate(
714
2113
  );
715
2114
  }
716
2115
 
2116
+ // ── Shared-role references + step domains (concept consolidation W4a) ─────
2117
+ checkSharedRoleReferences(tpl, resolveDepFrom(opts), add);
2118
+ checkStageDomains(tpl, resolveDepFrom(opts), add);
2119
+ checkProcessContract(tpl, resolveDepFrom(opts), opts.corpus, add);
2120
+ checkLifecycleDeclarations(tpl, resolveDepFrom(opts), opts.corpus, add);
2121
+ checkPresentationDeclarations(tpl, resolveDepFrom(opts), opts.corpus, add);
2122
+ checkIntent(tpl, resolveDepFrom(opts), add);
2123
+ checkStart(tpl, resolveDepFrom(opts), opts.corpus, add);
2124
+ checkPackDependencies(tpl, resolveDepFrom(opts), add);
2125
+
717
2126
  // ── Overlay-safety: an overlay (compose relation) is layered additively onto
718
2127
  // its base's workspace. The additive reconcile OVERWRITES workspace-level
719
2128
  // settings, so an overlay MUST set workspace.subtype to its compose-base's slug