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