@synap-core/workspace-templates 0.9.3 → 0.10.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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * CORPUS guard: every automation flow we SHIP must pass `validateFlowDefinition`.
3
+ *
4
+ * Why this file exists. `validateTemplate` has called `validateFlowDefinition`
5
+ * for a while (`validate.ts:588`), and the package had 83 passing tests — yet
6
+ * `operations.yaml`'s "Pick up handed-off clients" shipped with four write nodes
7
+ * orphaned outside their loop body, creating entities and relations with EMPTY
8
+ * ids on every run of every pod that installed it. The rule existed and the
9
+ * validator was wired; nothing ever ran the two together over the real corpus.
10
+ * A validator no test points at the shipped data is a validator that only ever
11
+ * checks fixtures.
12
+ *
13
+ * So this reads the SAME YAML the build reads (`readYamlTemplates`, the source
14
+ * the generator consumes — not `templates.ts`, which is generated output and
15
+ * could drift) and asserts the shipped flows are clean. It fails loudly with the
16
+ * per-node messages, because "which node, and why" is the whole value.
17
+ */
18
+
19
+ import { test } from "vitest";
20
+ import assert from "node:assert/strict";
21
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { readYamlTemplates } from "./generate.mjs";
25
+ import { validateFlowDefinition } from "./validate-flow.js";
26
+
27
+ interface ShippedAutomation {
28
+ readonly key?: string;
29
+ readonly name?: string;
30
+ readonly flow?: unknown;
31
+ }
32
+
33
+ test("corpus: every shipped automation flow passes validateFlowDefinition", () => {
34
+ const templates = readYamlTemplates();
35
+ const failures: string[] = [];
36
+ let flowsChecked = 0;
37
+
38
+ // `readYamlTemplates()` yields `{ file, slug, data }` — the parsed YAML is
39
+ // under `data`, NOT spread onto the entry.
40
+ for (const entry of templates) {
41
+ const { slug, data } = entry as {
42
+ slug?: string;
43
+ data?: { automations?: ShippedAutomation[] };
44
+ };
45
+ const automations = data?.automations;
46
+ if (!Array.isArray(automations)) continue;
47
+
48
+ for (const automation of automations) {
49
+ if (!automation?.flow) continue;
50
+ flowsChecked++;
51
+ const { valid, errors } = validateFlowDefinition(automation.flow);
52
+ if (valid) continue;
53
+ const label = automation.key ?? automation.name ?? "<unnamed>";
54
+ for (const error of errors) {
55
+ failures.push(
56
+ `${slug ?? "<template>"} › ${label} › ` +
57
+ `${error.nodeId ?? "-"} [${error.code}] ${error.message}`
58
+ );
59
+ }
60
+ }
61
+ }
62
+
63
+ // Guard the guard: if the corpus ever stops yielding flows (a rename in
64
+ // `readYamlTemplates`, a schema move), this test would "pass" while checking
65
+ // nothing — the exact failure mode it was written to prevent.
66
+ assert.ok(
67
+ flowsChecked > 0,
68
+ "no automation flows found in the shipped corpus — this guard is checking nothing"
69
+ );
70
+
71
+ assert.deepEqual(
72
+ failures,
73
+ [],
74
+ `shipped automation flows failed validation:\n ${failures.join("\n ")}`
75
+ );
76
+ });
77
+
78
+ // ── The SECOND corpus: Control-Plane capability seeds ────────────────────────
79
+ // The YAML sweep above was written after a shipped workspace template was found
80
+ // writing entities with empty ids. It did not catch the IDENTICAL defect in
81
+ // `client-comms-report.capability.json`, because that flow lives in a different
82
+ // repo, in JSON, and nothing pointed a validator at it. Same bug, same week,
83
+ // missed for the same reason: a corpus nobody sweeps.
84
+ //
85
+ // These seeds ship automations verbatim into user pods, so they are held to the
86
+ // same contract. Skips when the CP checkout is absent, like the drift guards.
87
+ const HERE = dirname(fileURLToPath(import.meta.url));
88
+ const CP_SEEDS = join(
89
+ HERE,
90
+ "../../../../synap-control-plane-api/src/seeds/capability-templates",
91
+ );
92
+
93
+ test("corpus: every CP capability seed's automation flow passes validateFlowDefinition", (t) => {
94
+ if (!existsSync(CP_SEEDS)) {
95
+ t.skip();
96
+ return;
97
+ }
98
+ const failures: string[] = [];
99
+ let flowsChecked = 0;
100
+
101
+ for (const file of readdirSync(CP_SEEDS).filter((f) => f.endsWith(".json"))) {
102
+ const seed = JSON.parse(readFileSync(join(CP_SEEDS, file), "utf8")) as {
103
+ automations?: Array<{ key?: string; name?: string; flowDefinition?: unknown }>;
104
+ };
105
+ for (const automation of seed.automations ?? []) {
106
+ if (!automation?.flowDefinition) continue;
107
+ flowsChecked++;
108
+ const { valid, errors } = validateFlowDefinition(automation.flowDefinition);
109
+ if (valid) continue;
110
+ const label = automation.key ?? automation.name ?? "<unnamed>";
111
+ for (const error of errors) {
112
+ failures.push(
113
+ `${file} › ${label} › ${error.nodeId ?? "-"} [${error.code}] ${error.message}`,
114
+ );
115
+ }
116
+ }
117
+ }
118
+
119
+ // Same self-guard as above: a rename in the seed shape must fail loudly
120
+ // rather than silently sweep nothing.
121
+ assert.ok(
122
+ flowsChecked > 0,
123
+ "no automation flows found in the CP capability seeds — this guard is checking nothing",
124
+ );
125
+ assert.deepEqual(
126
+ failures,
127
+ [],
128
+ `CP capability seed flows failed validation:\n ${failures.join("\n ")}`,
129
+ );
130
+ });
package/src/crm.yaml CHANGED
@@ -18,13 +18,13 @@ 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
21
+ # CRM is a website, so Browser opens it in its normal first-party web session.
22
+ # It deliberately does not use the SDK-hosted iframe/auth-broker path.
23
+ layout:
24
+ primarySurface:
25
+ kind: url
26
+ url: https://crm.synap.live
27
+ title: CRM
28
28
 
29
29
  # SSOT ordering guarantee — this template REFERENCES foundation's pod-wide
30
30
  # shared `client`/`partner`/`lead`/`churned` roles below. The apply engine seeds
@@ -63,6 +63,12 @@ profiles:
63
63
  icon: user
64
64
  color: amber
65
65
  description: A prospect, stakeholder, or contact.
66
+ # Pod-wide identity: `entityScope: pod` lands the ROWS with `workspace_id =
67
+ # NULL` (one person record visible from every workspace); the CRM lenses onto
68
+ # it via a role facet (lead/client). `person` is a pod-wide SYSTEM kind, so
69
+ # `scope` is omitted (no claim) — the applier reuses the system row and these
70
+ # CRM props land as this workspace's property OVERLAY.
71
+ entityScope: pod
66
72
  properties:
67
73
  - slug: email
68
74
  label: Email
@@ -123,7 +129,13 @@ profiles:
123
129
  icon: building-2
124
130
  color: "#10B981"
125
131
  description: An organisation being qualified or sold to.
126
- scope: WORKSPACE
132
+ # Pod-wide identity: `entityScope: pod` lands the ROWS with `workspace_id =
133
+ # NULL` (one company record visible from every workspace, auto-appearing on the
134
+ # Ecosystem graph), lensed onto this CRM by its role facet (lead/client).
135
+ # `company` is a pod-wide SYSTEM kind, so `scope` is omitted (no claim) — the
136
+ # applier reuses the system row and these CRM props land as an OVERLAY.
137
+ # (Was `scope: WORKSPACE`, which contradicted the pod-wide model.)
138
+ entityScope: pod
127
139
  properties:
128
140
  - slug: contextSummary
129
141
  label: Context
@@ -336,6 +348,14 @@ profiles:
336
348
  - healthy
337
349
  - at_risk
338
350
  - critical
351
+ - slug: driveLink
352
+ label: Drive folder
353
+ valueType: string
354
+ inputType: url
355
+ - slug: driveFolderId
356
+ label: Drive folder ID
357
+ valueType: string
358
+ inputType: text
339
359
 
340
360
  # ── Partner (referral source + co-delivery collaborator) ──────────────────
341
361
  - slug: partner
@@ -456,6 +476,21 @@ profiles:
456
476
  valueType: string
457
477
  inputType: textarea
458
478
 
479
+ # `linked_to_deal` is the GENERIC counterparty edge and stays the SSOT for "who
480
+ # is on this deal" — the handoff flow's `find-party` node reads exactly it and
481
+ # requires exactly one match, so nothing below may replace it.
482
+ #
483
+ # The two ROLE-QUALIFIED edges are additive: some deals are brokered, and the
484
+ # party on the client side is not the party on the partner side. `client` and
485
+ # `partner` are CO-ATTACHABLE facets on the same person/company (a facet is a
486
+ # hat, not an identity), so the side a party plays ON THIS DEAL cannot be read
487
+ # off its facets — the same company can be the client on one deal and the
488
+ # introducing partner on another. That is a property of the EDGE, which is why
489
+ # it is expressed as two relation types rather than promoted to a kind or
490
+ # inferred from a facet.
491
+ #
492
+ # Both are optional. A direct deal carries only `linked_to_deal` (+ optionally
493
+ # `client_on_deal` naming the same party); nothing requires a partner side.
459
494
  entityLinks:
460
495
  - sourceProfileSlug: person
461
496
  targetProfileSlug: deal
@@ -463,6 +498,88 @@ entityLinks:
463
498
  - sourceProfileSlug: company
464
499
  targetProfileSlug: deal
465
500
  type: linked_to_deal
501
+ - sourceProfileSlug: person
502
+ targetProfileSlug: deal
503
+ type: client_on_deal
504
+ label: Client side
505
+ - sourceProfileSlug: company
506
+ targetProfileSlug: deal
507
+ type: client_on_deal
508
+ label: Client side
509
+ - sourceProfileSlug: person
510
+ targetProfileSlug: deal
511
+ type: partner_on_deal
512
+ label: Partner side
513
+ - sourceProfileSlug: company
514
+ targetProfileSlug: deal
515
+ type: partner_on_deal
516
+ label: Partner side
517
+
518
+ onboarding:
519
+ goal: >
520
+ Turn the user's real commercial motion into a usable pipeline — the right
521
+ companies and people, their active opportunities, and an explicit next
522
+ step for every deal — so CRM becomes the place sales work moves forward.
523
+ framing: >
524
+ THE REVENUE OPERATOR: Act as a pragmatic sales lead setting up a CRM people
525
+ will actually maintain. Start from the user's existing motion, vocabulary,
526
+ and active conversations. Resolve before creating so imported or Pod-wide
527
+ people are reused rather than duplicated. Push for a concrete next step and
528
+ date; never invent revenue, probability, or contact details.
529
+ expertise:
530
+ starters:
531
+ - >
532
+ A useful first pipeline is deliberately small. Start with the few stages
533
+ that change how the team acts — contacted, qualifying, proposal,
534
+ negotiating, won, and lost — and keep the template's default stages
535
+ unless the user's real process needs a different view.
536
+ - >
537
+ Populate the workspace with live work, not a perfect historical archive:
538
+ propose the three to five opportunities the user is actively trying to
539
+ move, then add the companies and people needed to understand those deals.
540
+ - >
541
+ Every active deal needs one observable next step with a date. If the next
542
+ step is "follow up" or "keep warm", help turn it into a specific action
543
+ such as "send the revised proposal Thursday" before capturing it.
544
+ blindSpots:
545
+ - >
546
+ A deal without a real person is usually an account-shaped hope. If an
547
+ opportunity has no stakeholder, surface the gap instead of fabricating a
548
+ placeholder contact.
549
+ - >
550
+ Pipelines become fiction when stages describe confidence rather than an
551
+ event the buyer completed. Ask what evidence moves a deal into its current
552
+ stage, especially around proposal and negotiation.
553
+ - >
554
+ More fields do not create better sales hygiene. Prioritize stage, value
555
+ when known, expected close date, and next step; leave unverifiable fields
556
+ empty and offer enrichment separately.
557
+ bar: >
558
+ A sales lead can open the pipeline and immediately see the few live
559
+ opportunities, the real company and stakeholder behind each one, its
560
+ evidence-based stage, and the dated next action — with no duplicate people
561
+ and no invented commercial data.
562
+ collect:
563
+ - profileSlug: company
564
+ what: The real prospect organisations behind current conversations.
565
+ cardinality: few
566
+ keyFields: [website, industry, companySize, source, nextStep]
567
+ - profileSlug: person
568
+ what: The known stakeholders for those organisations and opportunities.
569
+ cardinality: few
570
+ keyFields: [email, jobTitle, company, source, nextFollowUpDate]
571
+ - profileSlug: deal
572
+ what: The live opportunities worth actively moving through the pipeline.
573
+ cardinality: few
574
+ keyFields: [dealStage, value, closeDate, nextStep]
575
+ openingQuestions:
576
+ - "Which three to five opportunities are you actively trying to move right now?"
577
+ - "For the most important one, which company and real stakeholder are involved?"
578
+ - "What has the buyer actually done so far, and what is the next dated action?"
579
+ doneWhen: >
580
+ At least one real opportunity is linked to its company or stakeholder and
581
+ has an evidence-based stage plus a concrete next step; the user can see how
582
+ to add or import the rest without duplicating existing people.
466
583
 
467
584
  # Playbook — enriches a lead/prospect from public sources (company site,
468
585
  # LinkedIn, firmographic signal) so ICP scoring and qualification start from
@@ -59,13 +59,11 @@ describe('workspace primary-surface conversion', () => {
59
59
  });
60
60
  });
61
61
 
62
- it('ships the generated CRM template as a hosted primary surface', () => {
62
+ it('ships the generated CRM template as an ordinary website surface', () => {
63
63
  expect(
64
64
  toWorkspaceDefinition('crm').definition.layoutConfig?.primarySurface
65
65
  ).toEqual({
66
- kind: 'app',
67
- appId: 'crm',
68
- rendererType: 'external',
66
+ kind: 'url',
69
67
  url: 'https://crm.synap.live',
70
68
  title: 'CRM',
71
69
  });
package/src/define.ts CHANGED
@@ -272,6 +272,19 @@ export interface WorkspaceDefinitionInput {
272
272
  seedEntities: NonNullable<WorkspaceYaml['seedEntities']>;
273
273
  suggestedRelations?: WorkspaceYaml['suggestedRelations'];
274
274
  displayTemplates?: WorkspaceYaml['displayTemplates'];
275
+ /**
276
+ * Domain-level relation TYPE vocabulary — the door find-or-creates a
277
+ * `relation_defs` row per entry (idempotent by slug within the workspace). NOT
278
+ * `entityLinks` (those are profile→profile edges and drop the rich metadata).
279
+ * Carried verbatim; the authored form IS the wire form.
280
+ */
281
+ relationDefs?: WorkspaceYaml['relationDefs'];
282
+ /**
283
+ * Workspace intelligence commands — the door find-or-creates an
284
+ * `intelligence_commands` row per entry (idempotent by title). Carried
285
+ * verbatim; the authored form IS the wire form.
286
+ */
287
+ commands?: WorkspaceYaml['commands'];
275
288
  layoutConfig: WorkspaceYaml['layout'] | undefined;
276
289
  bentoViewName?: string;
277
290
  /**
@@ -398,6 +411,11 @@ export function toWorkspaceDefinition(
398
411
  seedEntities: tpl.seedEntities ?? [],
399
412
  suggestedRelations: tpl.suggestedRelations,
400
413
  displayTemplates: tpl.displayTemplates,
414
+ // Relation-type vocabulary + intelligence commands — carried verbatim so the
415
+ // one reconcile door can seed the universal defaults (the `base` template)
416
+ // that three hardcoded seeders used to. Authored form IS the wire form.
417
+ relationDefs: tpl.relationDefs,
418
+ commands: tpl.commands,
401
419
  layoutConfig: normalizeTemplateLayout(tpl.layout, tpl.app),
402
420
  bentoViewName: tpl.bento?.viewName,
403
421
  bentoLayout: bento.bentoLayout,
@@ -472,6 +490,8 @@ type WorkspaceDefinitionForwarded =
472
490
  | 'seedEntities'
473
491
  | 'suggestedRelations'
474
492
  | 'displayTemplates'
493
+ | 'relationDefs' // → carried verbatim; door find-or-creates relation_defs rows
494
+ | 'commands' // → carried verbatim; door find-or-creates intelligence_commands rows
475
495
  | 'layout' // → layoutConfig
476
496
  | 'bento' // → bentoViewName + bentoLayout (widgets) + bentoViewBlocks (views)
477
497
  | 'profileEntityBentoTemplates'
@@ -544,6 +564,10 @@ export interface PackageDefinitionOutput {
544
564
  suggestedRelations?: WorkspaceYaml['suggestedRelations'];
545
565
  displayTemplates?: WorkspaceYaml['displayTemplates'];
546
566
  entityLinks?: WorkspaceYaml['entityLinks'];
567
+ /** Relation-type vocabulary — carried verbatim (authored form IS the wire form). */
568
+ relationDefs?: WorkspaceYaml['relationDefs'];
569
+ /** Intelligence commands — carried verbatim (authored form IS the wire form). */
570
+ commands?: WorkspaceYaml['commands'];
547
571
  /** WIDGET half of the authored bento union — mapped by the pod via `widgetType`. */
548
572
  bentoLayout?: BentoWidgetBlockWire[];
549
573
  /**
@@ -619,6 +643,8 @@ export function toPackageDefinition(
619
643
  suggestedRelations: tpl.suggestedRelations,
620
644
  displayTemplates: tpl.displayTemplates,
621
645
  entityLinks: tpl.entityLinks,
646
+ relationDefs: tpl.relationDefs,
647
+ commands: tpl.commands,
622
648
  bentoLayout: bento.bentoLayout,
623
649
  bentoViewBlocks: bento.bentoViewBlocks,
624
650
  bentoViewName: tpl.bento?.viewName,
@@ -650,6 +676,8 @@ type PackageDefinitionForwarded =
650
676
  | 'suggestedRelations'
651
677
  | 'displayTemplates'
652
678
  | 'entityLinks'
679
+ | 'relationDefs' // → carried verbatim
680
+ | 'commands' // → carried verbatim
653
681
  | 'bento' // → bentoViewName + bentoLayout (widgets) + bentoViewBlocks (views)
654
682
  | 'profileEntityBentoTemplates'
655
683
  | 'layout' // → layoutConfig
@@ -51,6 +51,14 @@ profiles:
51
51
  icon: user
52
52
  color: amber
53
53
  description: A person in the ecosystem — a founder, operator, or contact.
54
+ # Pod-wide identity. `person` is a pod-wide SYSTEM kind, so `entityScope: pod`
55
+ # is the load-bearing axis: the ROWS land with `workspace_id = NULL`, visible
56
+ # from every workspace (auto-appearing on the Ecosystem graph) and lensed onto
57
+ # a CRM only where it wears a role facet (lead/client/partner). `scope` is
58
+ # deliberately OMITTED: it is already a system profile (more pod-wide than
59
+ # `shared`), so any scope claim only forks the row-level SSOT (see
60
+ # shared-role-ssot.test.ts (c)); the applier reuses the existing system row.
61
+ entityScope: pod
54
62
  properties:
55
63
  - slug: email
56
64
  label: Email
@@ -66,6 +74,12 @@ profiles:
66
74
  icon: building-2
67
75
  color: "#10B981"
68
76
  description: A company in the ecosystem — a player you operate around.
77
+ # Pod-wide identity — same rationale as `person` above: `company` is a pod-wide
78
+ # SYSTEM kind, so `entityScope: pod` lands the ROWS with `workspace_id = NULL`
79
+ # (visible from every workspace, auto-appearing on the Ecosystem graph, lensed
80
+ # onto CRM only by its role facet). `scope` omitted so the applier reuses the
81
+ # existing system row without forking the row-level SSOT.
82
+ entityScope: pod
69
83
  properties:
70
84
  - slug: website
71
85
  label: Website
@@ -51,7 +51,7 @@ dependencies:
51
51
  - slug: business-developer
52
52
  kind: workspace
53
53
  relation: require
54
- reason: "The lead-first sales motion (crm-lead/crm-client roles, commercial proposals, commission handoff) that the CRM app runs on. Composes onto the CRM workspace above — without it the CRM lens is provisioned with pod-wide lead/client only and the app's pipeline is empty."
54
+ reason: "The lead-first sales motion (the business-development overlay on the pod-wide lead/client roles, commercial proposals, commission handoff) that the CRM app runs on. Composes onto the CRM workspace above — without it the CRM lens has the bare lead/client roles with no lifecycle or handoff fields and the app's pipeline is empty."
55
55
  - slug: content-os
56
56
  kind: workspace
57
57
  relation: require
@@ -371,6 +371,14 @@ profiles:
371
371
  - healthy
372
372
  - at_risk
373
373
  - critical
374
+ - slug: driveLink
375
+ label: Drive folder
376
+ valueType: string
377
+ inputType: url
378
+ - slug: driveFolderId
379
+ label: Drive folder ID
380
+ valueType: string
381
+ inputType: text
374
382
 
375
383
  # ── Partner (referral source + co-delivery collaborator) ──────────────────
376
384
  - slug: partner
package/src/grants.yaml CHANGED
@@ -730,6 +730,14 @@ profiles:
730
730
  - healthy
731
731
  - at_risk
732
732
  - critical
733
+ - slug: driveLink
734
+ label: Drive folder
735
+ valueType: string
736
+ inputType: url
737
+ - slug: driveFolderId
738
+ label: Drive folder ID
739
+ valueType: string
740
+ inputType: text
733
741
  # ── Partner (shared ecosystem role — SSOT is foundation.yaml) ─────────────
734
742
  - slug: partner
735
743
  displayName: Partner
package/src/life-os.yaml CHANGED
@@ -207,7 +207,12 @@ profiles:
207
207
  icon: User
208
208
  color: "#06B6D4"
209
209
  description: People in your intellectual and professional world.
210
- scope: WORKSPACE
210
+ # Pod-wide identity: `person` is ONE pod-wide SYSTEM kind, not a life-os-local
211
+ # copy. `entityScope: pod` lands the ROWS with `workspace_id = NULL` (visible
212
+ # from every workspace); `scope` omitted (no claim) so the applier reuses the
213
+ # system row and these life-os props land as this workspace's OVERLAY.
214
+ # (Was `scope: WORKSPACE`, which contradicted the pod-wide model.)
215
+ entityScope: pod
211
216
  properties:
212
217
  - slug: name
213
218
  label: Name