@synap-core/workspace-templates 0.9.2 → 0.9.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/workspace-templates",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
@@ -86,14 +86,13 @@ profiles:
86
86
  inputType: select
87
87
  enumValues:
88
88
  [
89
- manual,
90
- referral,
89
+ cold-outreach,
91
90
  linkedin,
92
- email,
91
+ twitter,
92
+ landing-page,
93
+ inbound-call,
94
+ referral,
93
95
  event,
94
- inbound,
95
- partner,
96
- import,
97
96
  other,
98
97
  ]
99
98
  - slug: icpScore
@@ -352,6 +351,25 @@ profiles:
352
351
  label: Notes
353
352
  valueType: string
354
353
  inputType: textarea
354
+ # `campaign-type` is the campaign's NATURE/phase, orthogonal to
355
+ # `campaign-channel` (the delivery medium). scoping/outreach/live have no
356
+ # channel equivalent, so folding them into `campaign-channel` would lose
357
+ # them — they get their own def. Kept verbatim across the three campaign
358
+ # declarations (marketing / content-os / here) so there is no drift.
359
+ - slug: campaign-type
360
+ label: Type
361
+ valueType: string
362
+ inputType: select
363
+ enumValues:
364
+ [scoping, outreach, live, partnership, event, other]
365
+ constraints: { defaultValue: outreach }
366
+ # `campaign-color` backs the per-campaign colour the CRM surfaces render.
367
+ # Stored as a palette NAME (see apps/crm/lib/campaignColors.ts), not a hex.
368
+ - slug: campaign-color
369
+ label: Color
370
+ valueType: string
371
+ inputType: select
372
+ enumValues: [indigo, violet, amber, cyan, pink, teal]
355
373
 
356
374
  entityLinks:
357
375
  # The existing generic `linked_to_deal` relation remains the deal ↔
@@ -623,6 +623,25 @@ profiles:
623
623
  label: Notes
624
624
  valueType: string
625
625
  inputType: textarea
626
+ # `campaign-type` is the campaign's NATURE/phase, orthogonal to
627
+ # `campaign-channel` (the delivery medium). scoping/outreach/live have no
628
+ # channel equivalent, so folding them into `campaign-channel` would lose
629
+ # them — they get their own def. Kept verbatim across the three campaign
630
+ # declarations (business-developer / marketing / here) so there is no drift.
631
+ - slug: campaign-type
632
+ label: Type
633
+ valueType: string
634
+ inputType: select
635
+ enumValues:
636
+ [scoping, outreach, live, partnership, event, other]
637
+ constraints: { defaultValue: outreach }
638
+ # `campaign-color` backs the per-campaign colour the CRM surfaces render.
639
+ # Stored as a palette NAME (see apps/crm/lib/campaignColors.ts), not a hex.
640
+ - slug: campaign-color
641
+ label: Color
642
+ valueType: string
643
+ inputType: select
644
+ enumValues: [indigo, violet, amber, cyan, pink, teal]
626
645
 
627
646
  # ── Sponsor (shared ecosystem role — SSOT is foundation.yaml) ─────────────
628
647
  # `scope: shared` dedups to the ONE pod-wide `sponsor` role seeded by
package/src/crm.yaml CHANGED
@@ -18,6 +18,14 @@ meta:
18
18
  - contacts
19
19
  isPublic: true
20
20
 
21
+ # Canonical hosted surface. Browser resolves auth from appId and launches this
22
+ # URL with non-secret workspace/Pod context; CRM establishes its own session.
23
+ app:
24
+ kind: url
25
+ appId: crm
26
+ url: https://crm.synap.live
27
+ title: CRM
28
+
21
29
  # SSOT ordering guarantee — this template REFERENCES foundation's pod-wide
22
30
  # shared `client`/`partner`/`lead`/`churned` roles below. The apply engine seeds
23
31
  # a shared profile's pod-wide BASE property body from whichever template
@@ -79,7 +87,16 @@ profiles:
79
87
  - slug: source
80
88
  label: Source
81
89
  valueType: string
82
- inputType: text
90
+ inputType: select
91
+ enumValues:
92
+ - cold-outreach
93
+ - linkedin
94
+ - twitter
95
+ - landing-page
96
+ - inbound-call
97
+ - referral
98
+ - event
99
+ - other
83
100
  - slug: icpScore
84
101
  label: ICP score
85
102
  valueType: number
@@ -141,7 +158,16 @@ profiles:
141
158
  - slug: source
142
159
  label: Source
143
160
  valueType: string
144
- inputType: text
161
+ inputType: select
162
+ enumValues:
163
+ - cold-outreach
164
+ - linkedin
165
+ - twitter
166
+ - landing-page
167
+ - inbound-call
168
+ - referral
169
+ - event
170
+ - other
145
171
  - slug: icpScore
146
172
  label: ICP score
147
173
  valueType: number
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ normalizeTemplateLayout,
4
+ toPackageDefinition,
5
+ toWorkspaceDefinition,
6
+ } from './define.js';
7
+ import type { WorkspaceYaml } from './types.js';
8
+ import { validateTemplate } from './validate.js';
9
+
10
+ function template(
11
+ overrides: Partial<Pick<WorkspaceYaml, 'app' | 'layout' | 'views' | 'bento'>>
12
+ ): WorkspaceYaml {
13
+ return {
14
+ meta: {
15
+ slug: 'surface-contract-test',
16
+ name: 'Surface contract test',
17
+ description: 'Surface contract fixture',
18
+ icon: 'panels-top-left',
19
+ color: 'blue',
20
+ },
21
+ workspace: {
22
+ name: 'Surface contract test',
23
+ description: 'Surface contract fixture',
24
+ },
25
+ profiles: [{ slug: 'thing', displayName: 'Thing', properties: [] }],
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe('workspace primary-surface conversion', () => {
31
+ it('normalizes an external app into both provisioning doors', () => {
32
+ const source = template({
33
+ app: {
34
+ kind: 'url',
35
+ appId: 'crm',
36
+ url: 'https://crm.synap.live',
37
+ title: 'CRM',
38
+ },
39
+ layout: { pinnedApps: ['data'] },
40
+ });
41
+
42
+ const expected = {
43
+ kind: 'app',
44
+ appId: 'crm',
45
+ rendererType: 'external',
46
+ url: 'https://crm.synap.live',
47
+ title: 'CRM',
48
+ };
49
+
50
+ expect(toWorkspaceDefinition(source).definition.layoutConfig).toMatchObject(
51
+ {
52
+ pinnedApps: ['data'],
53
+ primarySurface: expected,
54
+ }
55
+ );
56
+ expect(toPackageDefinition(source).layoutConfig).toMatchObject({
57
+ pinnedApps: ['data'],
58
+ primarySurface: expected,
59
+ });
60
+ });
61
+
62
+ it('ships the generated CRM template as a hosted primary surface', () => {
63
+ expect(
64
+ toWorkspaceDefinition('crm').definition.layoutConfig?.primarySurface
65
+ ).toEqual({
66
+ kind: 'app',
67
+ appId: 'crm',
68
+ rendererType: 'external',
69
+ url: 'https://crm.synap.live',
70
+ title: 'CRM',
71
+ });
72
+ });
73
+
74
+ it('keeps legacy defaultApp as a native compatibility input', () => {
75
+ expect(
76
+ normalizeTemplateLayout({ defaultApp: 'dashboard' }, undefined)
77
+ ).toMatchObject({
78
+ defaultApp: 'dashboard',
79
+ primarySurface: {
80
+ kind: 'app',
81
+ appId: 'dashboard',
82
+ rendererType: 'native',
83
+ },
84
+ });
85
+ });
86
+
87
+ it('keeps an authored view name unresolved through both provisioning doors', () => {
88
+ const source = template({
89
+ views: [
90
+ {
91
+ name: 'Pipeline',
92
+ slug: 'pipeline',
93
+ type: 'kanban',
94
+ scopeProfileSlug: 'thing',
95
+ },
96
+ ],
97
+ layout: {
98
+ primarySurface: {
99
+ kind: 'view',
100
+ viewName: 'Pipeline',
101
+ viewSlug: 'pipeline',
102
+ },
103
+ },
104
+ });
105
+
106
+ expect(toWorkspaceDefinition(source).definition.layoutConfig).toMatchObject(
107
+ {
108
+ primarySurface: {
109
+ kind: 'view',
110
+ viewName: 'Pipeline',
111
+ viewSlug: 'pipeline',
112
+ },
113
+ }
114
+ );
115
+ expect(toPackageDefinition(source).layoutConfig).toMatchObject({
116
+ primarySurface: {
117
+ kind: 'view',
118
+ viewName: 'Pipeline',
119
+ viewSlug: 'pipeline',
120
+ },
121
+ });
122
+ expect(validateTemplate(source).ok).toBe(true);
123
+ });
124
+
125
+ it('rejects an unknown authored primary view before provisioning', () => {
126
+ const source = template({
127
+ views: [{ name: 'Pipeline', type: 'kanban' }],
128
+ layout: {
129
+ primarySurface: { kind: 'view', viewName: 'Missing' },
130
+ },
131
+ });
132
+
133
+ expect(validateTemplate(source).errors).toContainEqual(
134
+ expect.objectContaining({ rule: 'primary-view-reference-unknown' })
135
+ );
136
+ });
137
+
138
+ it('does not activate historical entity-detail placeholders without identity', () => {
139
+ expect(
140
+ normalizeTemplateLayout(
141
+ { defaultApp: null },
142
+ { kind: 'stored', cellKey: 'entity-detail' }
143
+ )
144
+ ).toMatchObject({
145
+ defaultApp: null,
146
+ primarySurface: null,
147
+ });
148
+ });
149
+
150
+ it('rejects two competing authoring sources', () => {
151
+ const source = template({
152
+ app: { kind: 'native', appId: 'dashboard' },
153
+ layout: {
154
+ primarySurface: {
155
+ kind: 'app',
156
+ appId: 'data',
157
+ rendererType: 'native',
158
+ },
159
+ },
160
+ });
161
+
162
+ expect(validateTemplate(source).errors).toContainEqual(
163
+ expect.objectContaining({ rule: 'primary-surface-authoring-conflict' })
164
+ );
165
+ expect(() => toWorkspaceDefinition(source)).toThrow(
166
+ /both top-level app and layout\.primarySurface/
167
+ );
168
+ });
169
+
170
+ it('rejects a hosted app that cannot be authorized by application id', () => {
171
+ const source = template({
172
+ app: { kind: 'url', url: 'https://crm.synap.live' },
173
+ layout: {},
174
+ });
175
+
176
+ expect(validateTemplate(source).errors).toContainEqual(
177
+ expect.objectContaining({ rule: 'app-url-identity-required' })
178
+ );
179
+ expect(() => toWorkspaceDefinition(source)).toThrow(
180
+ /requires both appId and url/
181
+ );
182
+ });
183
+ });
package/src/define.ts CHANGED
@@ -10,8 +10,12 @@
10
10
  * shape never drifts per call-site.
11
11
  */
12
12
 
13
- import type { TemplateAutomation, TemplateBentoBlock, WorkspaceYaml } from "./types.js";
14
- import { getWorkspaceTemplate } from "./templates.js";
13
+ import type {
14
+ TemplateAppRef, TemplateAutomation, TemplateBentoBlock,
15
+ TemplateLayoutConfig,
16
+ WorkspacePrimarySurface,
17
+ WorkspaceYaml, } from './types.js';
18
+ import { getWorkspaceTemplate } from './templates.js';
15
19
 
16
20
  /**
17
21
  * A `TemplateAutomation` as the apply door expects it: `flowDefinition`, not
@@ -25,12 +29,12 @@ export interface PackageAutomationWire {
25
29
  description?: string;
26
30
  triggerType: TemplateAutomation['triggerType'];
27
31
  triggerConfig?: Record<string, unknown>;
28
- flowDefinition?: { nodes: unknown[]; edges: unknown[]; precondition?: string };
32
+ flowDefinition?: { nodes: unknown[]; edges: unknown[]; precondition?: string; };
29
33
  status?: TemplateAutomation['status'];
30
34
  }
31
35
 
32
36
  function toAutomationWire(
33
- automations: TemplateAutomation[] | undefined,
37
+ automations: TemplateAutomation[] | undefined
34
38
  ): PackageAutomationWire[] | undefined {
35
39
  return automations?.map((a) => ({
36
40
  key: a.key,
@@ -63,6 +67,122 @@ export interface SplitBentoBlocks {
63
67
  bentoViewBlocks: BentoViewBlockWire[];
64
68
  }
65
69
 
70
+ const hasOwn = <K extends PropertyKey>(
71
+ value: object,
72
+ key: K
73
+ ): value is Record<K, unknown> =>
74
+ Object.prototype.hasOwnProperty.call(value, key);
75
+
76
+ /**
77
+ * Convert the legacy top-level `app:` authoring shorthand into the persisted
78
+ * workspace surface contract. The conversion is deliberately conservative:
79
+ * an external app without an application id cannot pass the host's
80
+ * application/origin authorization, and the historical `entity-detail`
81
+ * placeholders have no entity identity to render.
82
+ */
83
+ function appRefToPrimarySurface(
84
+ app: TemplateAppRef | undefined
85
+ ): WorkspacePrimarySurface | undefined {
86
+ if (!app) return undefined;
87
+
88
+ if (app.kind === 'native') {
89
+ if (!app.appId) {
90
+ throw new Error('Workspace template native app is missing appId');
91
+ }
92
+ return {
93
+ kind: 'app',
94
+ appId: app.appId,
95
+ rendererType: 'native',
96
+ ...(app.title ? { title: app.title } : {}),
97
+ ...(app.props ? { props: app.props } : {}),
98
+ };
99
+ }
100
+
101
+ if (app.kind === 'url') {
102
+ if (!app.appId || !app.url) {
103
+ throw new Error(
104
+ 'Workspace template URL app requires both appId and url'
105
+ );
106
+ }
107
+ return {
108
+ kind: 'app',
109
+ appId: app.appId,
110
+ rendererType: 'external',
111
+ url: app.url,
112
+ ...(app.title ? { title: app.title } : {}),
113
+ ...(app.props ? { props: app.props } : {}),
114
+ };
115
+ }
116
+
117
+ if (app.kind === 'stored') {
118
+ if (!app.cellKey) {
119
+ throw new Error('Workspace template stored app is missing cellKey');
120
+ }
121
+ // Twenty-two historical templates carry this inert placeholder without an
122
+ // entity id. Activating it would open an unusable detail cell on entry.
123
+ if (app.cellKey === 'entity-detail' && !app.props?.entityId) {
124
+ return undefined;
125
+ }
126
+ return {
127
+ kind: 'cell',
128
+ cellKey: app.cellKey,
129
+ ...(app.title ? { title: app.title } : {}),
130
+ ...(app.props ? { props: app.props } : {}),
131
+ };
132
+ }
133
+
134
+ return undefined;
135
+ }
136
+
137
+ /**
138
+ * Produce the canonical persisted layout. A template may author either the
139
+ * top-level `app:` shorthand or an explicit `layout.primarySurface`, never both.
140
+ * Legacy `defaultApp` is the final compatibility fallback. Keeping the property
141
+ * absent preserves a live workspace value during reconciliation; an authored
142
+ * null is retained as an explicit clear.
143
+ */
144
+ export function normalizeTemplateLayout(
145
+ layout: TemplateLayoutConfig | undefined,
146
+ app: TemplateAppRef | undefined
147
+ ): TemplateLayoutConfig | undefined {
148
+ if (app && layout && hasOwn(layout, 'primarySurface')) {
149
+ throw new Error(
150
+ 'Workspace template declares both top-level app and layout.primarySurface; choose one authoring source'
151
+ );
152
+ }
153
+
154
+ const primarySurface = (() => {
155
+ if (layout && hasOwn(layout, 'primarySurface')) {
156
+ return layout.primarySurface;
157
+ }
158
+
159
+ const fromApp = appRefToPrimarySurface(app);
160
+ if (fromApp) return fromApp;
161
+
162
+ if (layout && hasOwn(layout, 'defaultApp')) {
163
+ return layout.defaultApp
164
+ ? {
165
+ kind: 'app' as const,
166
+ appId: layout.defaultApp,
167
+ rendererType: 'native' as const,
168
+ }
169
+ : null;
170
+ }
171
+
172
+ return undefined;
173
+ })();
174
+
175
+ if (!layout && primarySurface === undefined) return undefined;
176
+
177
+ return {
178
+ ...layout,
179
+ ...(primarySurface !== undefined ||
180
+ (layout && hasOwn(layout, 'primarySurface'))
181
+ ? { primarySurface }
182
+ : {}),
183
+ };
184
+ }
185
+
66
186
  /**
67
187
  * Split the authored `bento.blocks` DISCRIMINATED UNION into the TWO wire
68
188
  * fields the pod expects. This is the whole point: `TemplateBentoBlock` is one
@@ -229,12 +349,14 @@ export interface ToDefinitionResult {
229
349
  * or selected value string. Keep this conversion at the shared template seam
230
350
  * so browser creation and package application cannot silently disagree.
231
351
  */
232
- function toWorkspacePlaybooks(playbooks: WorkspaceYaml["playbooks"]): Array<Record<string, unknown>> | undefined {
352
+ function toWorkspacePlaybooks(
353
+ playbooks: WorkspaceYaml['playbooks']
354
+ ): Array<Record<string, unknown>> | undefined {
233
355
  return playbooks?.map((playbook) => ({
234
356
  ...playbook,
235
357
  params: playbook.params?.map((param) => ({
236
358
  name: param.name,
237
- type: param.type === "number" || param.type === "boolean" ? param.type : "string",
359
+ type: param.type === 'number' || param.type === 'boolean' ? param.type : 'string',
238
360
  ...(param.defaultValue !== undefined ? { default: param.defaultValue } : {}),
239
361
  ...(param.label
240
362
  ? { description: param.label }
@@ -249,7 +371,7 @@ function toWorkspacePlaybooks(playbooks: WorkspaceYaml["playbooks"]): Array<Reco
249
371
  */
250
372
  export function toWorkspaceDefinition(
251
373
  slugOrYaml: string | WorkspaceYaml,
252
- nameOverride?: string,
374
+ nameOverride?: string
253
375
  ): ToDefinitionResult {
254
376
  const tpl: WorkspaceYaml | undefined =
255
377
  typeof slugOrYaml === 'string' ? getWorkspaceTemplate(slugOrYaml) : slugOrYaml;
@@ -276,7 +398,7 @@ export function toWorkspaceDefinition(
276
398
  seedEntities: tpl.seedEntities ?? [],
277
399
  suggestedRelations: tpl.suggestedRelations,
278
400
  displayTemplates: tpl.displayTemplates,
279
- layoutConfig: tpl.layout,
401
+ layoutConfig: normalizeTemplateLayout(tpl.layout, tpl.app),
280
402
  bentoViewName: tpl.bento?.viewName,
281
403
  bentoLayout: bento.bentoLayout,
282
404
  bentoViewBlocks: bento.bentoViewBlocks,
@@ -315,7 +437,7 @@ export function toWorkspaceDefinition(
315
437
  dependencies: tpl.dependencies,
316
438
  };
317
439
 
318
- return { definition, proposalId, workspaceName: name, templateId: tpl.meta.slug };
440
+ return { definition, proposalId, workspaceName: name, templateId: tpl.meta.slug, };
319
441
  }
320
442
 
321
443
  // ─── Exhaustiveness ledger: toWorkspaceDefinition ────────────────────────────
@@ -366,14 +488,11 @@ type WorkspaceDefinitionForwarded =
366
488
  * else is a real gap and should say so.
367
489
  */
368
490
  type WorkspaceDefinitionDropped =
369
- // UI-only: `workspaces.createFromDefinition` has no `app` field. Consumers
370
- // read `template.app` straight off the YAML to pick the first screen, so this
371
- // never belonged on the wire.
491
+ // Normalized into `layoutConfig.primarySurface`.
372
492
  | 'app'
373
493
  // The door DOES accept `extends`, but no template declares it today (0/21).
374
494
  // Forwarding is a no-op until one does — wire it then.
375
- | 'extends'
376
- ;
495
+ | 'extends';
377
496
 
378
497
  type UnclassifiedWorkspaceDefinitionKey = Exclude<
379
498
  keyof WorkspaceYaml,
@@ -383,7 +502,9 @@ type UnclassifiedWorkspaceDefinitionKey = Exclude<
383
502
  // If this errors, a new `WorkspaceYaml` field is unclassified. Forward it in
384
503
  // `toWorkspaceDefinition` and add it to `WorkspaceDefinitionForwarded`, or add
385
504
  // it to `WorkspaceDefinitionDropped` with a one-line WHY.
386
- const _workspaceDefinitionLedger: [UnclassifiedWorkspaceDefinitionKey] extends [never]
505
+ const _workspaceDefinitionLedger: [UnclassifiedWorkspaceDefinitionKey] extends [
506
+ never,
507
+ ]
387
508
  ? true
388
509
  : [
389
510
  'UNCLASSIFIED YAML FIELD — forward it or add it to WorkspaceDefinitionDropped:',
@@ -462,7 +583,7 @@ export interface PackageDefinitionOutput {
462
583
  * package instead of disk-stored JSON.
463
584
  */
464
585
  export function toPackageDefinition(
465
- slugOrYaml: string | WorkspaceYaml,
586
+ slugOrYaml: string | WorkspaceYaml
466
587
  ): PackageDefinitionOutput {
467
588
  const tpl: WorkspaceYaml | undefined =
468
589
  typeof slugOrYaml === 'string' ? getWorkspaceTemplate(slugOrYaml) : slugOrYaml;
@@ -502,7 +623,7 @@ export function toPackageDefinition(
502
623
  bentoViewBlocks: bento.bentoViewBlocks,
503
624
  bentoViewName: tpl.bento?.viewName,
504
625
  profileEntityBentoTemplates: tpl.profileEntityBentoTemplates,
505
- layoutConfig: tpl.layout,
626
+ layoutConfig: normalizeTemplateLayout(tpl.layout, tpl.app),
506
627
  capabilities: tpl.integrations,
507
628
  playbooks: tpl.playbooks,
508
629
  automations: toAutomationWire(tpl.automations),
@@ -541,7 +662,7 @@ type PackageDefinitionForwarded =
541
662
 
542
663
  /** Keys deliberately NOT forwarded by `toPackageDefinition`. */
543
664
  type PackageDefinitionDropped =
544
- // UI-only, same as the definition door: `PackageDefinition` has no `app`.
665
+ // Normalized into `layoutConfig.primarySurface`.
545
666
  | 'app'
546
667
  // REAL GAP: backend `PackageDefinition` DOES declare `extends`
547
668
  // (package-definition.ts:262), but `PackageDefinitionOutput` omits it. No
@@ -557,7 +678,9 @@ type UnclassifiedPackageDefinitionKey = Exclude<
557
678
  // If this errors, a new `WorkspaceYaml` field is unclassified. Forward it in
558
679
  // `toPackageDefinition` and add it to `PackageDefinitionForwarded`, or add it
559
680
  // to `PackageDefinitionDropped` with a one-line WHY.
560
- const _packageDefinitionLedger: [UnclassifiedPackageDefinitionKey] extends [never]
681
+ const _packageDefinitionLedger: [UnclassifiedPackageDefinitionKey] extends [
682
+ never,
683
+ ]
561
684
  ? true
562
685
  : [
563
686
  'UNCLASSIFIED YAML FIELD — forward it or add it to PackageDefinitionDropped:',
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * const template = getWorkspaceTemplate('brand-library');
12
12
  * // template.workspace.name === 'Brand Library'
13
13
  * // template.app.kind === 'native'
14
- * // template.layout.defaultApp === 'brandLibrary'
14
+ * // template.app.appId === 'brandLibrary'
15
15
  *
16
16
  * YAML source files live in src/*.yaml — AI-friendly, commentable, mergeable.
17
17
  * Run `pnpm build` to regenerate src/templates.ts from YAML.
@@ -22,6 +22,10 @@ export type {
22
22
  TemplateMeta,
23
23
  TemplateAppRef,
24
24
  TemplateAppKind,
25
+ WorkspaceJsonValue,
26
+ WorkspacePrimarySurface,
27
+ WorkspacePrimarySurfaceViewReference,
28
+ TemplatePrimarySurface,
25
29
  TemplateProfile,
26
30
  TemplateProperty,
27
31
  TemplateView,
@@ -86,8 +90,7 @@ export type {
86
90
  } from "./catalog.js";
87
91
 
88
92
  export {
89
- layerTemplateGraph,
90
- bundledEdgesOf,
93
+ layerTemplateGraph, bundledEdgesOf
91
94
  } from "./composition-graph.js";
92
95
  export type {
93
96
  CompositionInstalledEntry,
@@ -138,10 +141,30 @@ export type {
138
141
  TemplateConverters,
139
142
  } from "./validate.js";
140
143
 
144
+ // The pure, DB-free automation-flow node-contract validator. `validateTemplate`
145
+ // runs it over every `automations[].flow` so broken flows (unknown node type,
146
+ // dangling edge, cycle, typo'd channelType, verb-less capability node) fail at
147
+ // author time instead of silently never firing on a live pod. Exported so the
148
+ // pod's persist doors + the (post-publish) backend re-export share this SSOT.
149
+
150
+ export {
151
+ validateFlowDefinition,
152
+ flowValidationErrorMessage,
153
+ FLOW_NODE_TYPES,
154
+ FLOW_OUTPUT_TYPES,
155
+ CHANNEL_MESSAGE_CHANNEL_TYPES,
156
+ } from "./validate-flow.js";
157
+ export type {
158
+ FlowValidationError,
159
+ FlowValidationResult,
160
+ FlowValidationResolvers,
161
+ } from "./validate-flow.js";
162
+
141
163
  export {
142
164
  toWorkspaceDefinition,
143
165
  toPackageDefinition,
144
166
  splitBentoBlocks,
167
+ normalizeTemplateLayout,
145
168
  } from "./define.js";
146
169
  export type {
147
170
  WorkspaceDefinitionInput,
@@ -92,6 +92,25 @@ profiles:
92
92
  label: Notes
93
93
  valueType: string
94
94
  inputType: textarea
95
+ # `campaign-type` is the campaign's NATURE/phase, orthogonal to
96
+ # `campaign-channel` (the delivery medium). scoping/outreach/live have no
97
+ # channel equivalent, so folding them into `campaign-channel` would lose
98
+ # them — they get their own def. Kept verbatim across the three campaign
99
+ # declarations (business-developer / content-os / here) so there is no drift.
100
+ - slug: campaign-type
101
+ label: Type
102
+ valueType: string
103
+ inputType: select
104
+ enumValues:
105
+ [scoping, outreach, live, partnership, event, other]
106
+ constraints: { defaultValue: outreach }
107
+ # `campaign-color` backs the per-campaign colour the CRM surfaces render.
108
+ # Stored as a palette NAME (see apps/crm/lib/campaignColors.ts), not a hex.
109
+ - slug: campaign-color
110
+ label: Color
111
+ valueType: string
112
+ inputType: select
113
+ enumValues: [indigo, violet, amber, cyan, pink, teal]
95
114
 
96
115
  - slug: contact
97
116
  displayName: Contact