@synap-core/workspace-templates 0.9.4 → 0.11.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.
@@ -43,14 +43,25 @@ describe("concept guard — shipped corpus", () => {
43
43
  ).toEqual([]);
44
44
  });
45
45
 
46
- it("still sees the two-tier CRM divergence — declared, not absent", () => {
47
- // If business-developer's crm-lead/crm-client ever stopped colliding with
48
- // foundation's lead/client, the allowlist would be stale and rule 3 would
49
- // fire. This asserts the allowlist is load-bearing, not inert.
50
- const withoutAllowlist = Object.keys(DECLARED_DIVERGENCES);
51
- expect(withoutAllowlist).toEqual(["crm-lead|lead", "client|crm-client"]);
52
- expect(WORKSPACE_TEMPLATES["business-developer"]?.profiles.find((p) => p.slug === "crm-lead")
53
- ?.semanticSlug).toBe("crm.lead");
46
+ it("needs NO declared divergence — the CRM fork was resolved, not allowlisted", () => {
47
+ // The allowlist was born carrying `crm-lead|lead` + `client|crm-client`.
48
+ // business-developer.yaml now re-declares the SHARED `lead`/`client` roles
49
+ // and carries its business-development fields as workspace-overlay
50
+ // properties, so there is no fork left to declare. Rule 3 (stale-allowlist)
51
+ // is what forces this to stay true: a leftover entry fails CI.
52
+ expect(Object.keys(DECLARED_DIVERGENCES)).toEqual([]);
53
+
54
+ const bd = WORKSPACE_TEMPLATES["business-developer"]?.profiles ?? [];
55
+ expect(bd.map((p) => p.slug)).not.toContain("crm-lead");
56
+ expect(bd.map((p) => p.slug)).not.toContain("crm-client");
57
+ for (const slug of ["lead", "client"]) {
58
+ const reused = bd.find((p) => p.slug === slug);
59
+ expect(reused, `business-developer must reuse shared '${slug}'`).toBeDefined();
60
+ expect(reused?.scope).toBe("shared");
61
+ expect(reused?.profileKind).toBe("role");
62
+ // No alias: the shared slug IS the concept identity.
63
+ expect(reused?.semanticSlug).toBeUndefined();
64
+ }
54
65
  });
55
66
  });
56
67
 
@@ -71,33 +82,36 @@ describe("concept guard — fires on a real fork", () => {
71
82
  "some-addon": [role("channel-partner", { scope: "WORKSPACE" })],
72
83
  })
73
84
  );
74
- // Fixture corpora never match the real allowlist, so the stale-allowlist
75
- // findings are expected noise here — the collision is what is under test.
76
- const forks = violations.filter((v) => v.rule !== "stale-allowlist");
77
- expect(forks).toHaveLength(1);
78
- expect(forks[0]!.rule).toBe("concept-collision");
79
- expect(forks[0]!.concept).toBe("partner");
80
- expect(forks[0]!.message).toContain("channel-partner");
85
+ expect(violations).toHaveLength(1);
86
+ expect(violations[0]!.rule).toBe("concept-collision");
87
+ expect(violations[0]!.concept).toBe("partner");
88
+ expect(violations[0]!.message).toContain("channel-partner");
81
89
  });
82
90
 
83
91
  it("suppresses exactly the pair an author declared — and nothing else", () => {
84
- // The SAME shape as the test above, but with the slugs the allowlist names.
92
+ // The SAME shape as the test above, but with an allowlist naming the pair.
85
93
  // This is the whole safety property of the escape hatch: it is per-pair, not
86
- // per-concept, so a THIRD `lead` spelling would still fail.
94
+ // per-concept, so a THIRD `lead` spelling would still fail. The allowlist is
95
+ // INJECTED because the shipped one is (healthily) empty — the guard must
96
+ // still behave correctly the next time someone genuinely needs an entry.
97
+ const allowlist = { "crm-lead|lead": "declared for this fixture" };
87
98
  const declared = findConceptViolations(
88
99
  corpus({
89
100
  foundation: [role("lead", { scope: "shared" })],
90
101
  "business-developer": [role("crm-lead", { semanticSlug: "crm.lead", scope: "WORKSPACE" })],
91
- })
102
+ }),
103
+ allowlist
92
104
  );
93
105
  expect(declared.filter((v) => v.rule === "concept-collision")).toEqual([]);
106
+ expect(declared.filter((v) => v.rule === "stale-allowlist")).toEqual([]);
94
107
 
95
108
  const third = findConceptViolations(
96
109
  corpus({
97
110
  foundation: [role("lead", { scope: "shared" })],
98
111
  "business-developer": [role("crm-lead", { semanticSlug: "crm.lead", scope: "WORKSPACE" })],
99
112
  "another-addon": [role("sales-lead", { scope: "WORKSPACE" })],
100
- })
113
+ }),
114
+ allowlist
101
115
  );
102
116
  expect(third.filter((v) => v.rule === "concept-collision")).toHaveLength(2);
103
117
  });
@@ -109,9 +123,7 @@ describe("concept guard — fires on a real fork", () => {
109
123
  other: [role("pipeline-lead", { semanticSlug: "sales.lead" })],
110
124
  })
111
125
  );
112
- expect(violations.filter((v) => v.rule !== "stale-allowlist").map((v) => v.rule)).toEqual([
113
- "concept-collision",
114
- ]);
126
+ expect(violations.map((v) => v.rule)).toEqual(["concept-collision"]);
115
127
  });
116
128
 
117
129
  it("fails when two aliased profiles disagree on shape", () => {
@@ -125,11 +137,15 @@ describe("concept guard — fires on a real fork", () => {
125
137
  });
126
138
 
127
139
  it("fails when an allowlist entry stops matching anything", () => {
128
- // DECLARED_DIVERGENCES is module-level, so an empty corpus makes every
129
- // entry stale — exactly the state a resolved divergence would leave behind.
130
- const violations = findConceptViolations(corpus({}));
131
- expect(violations.every((v) => v.rule === "stale-allowlist")).toBe(true);
132
- expect(violations).toHaveLength(Object.keys(DECLARED_DIVERGENCES).length);
140
+ // An entry whose collision no longer exists — exactly the state resolving a
141
+ // divergence leaves behind, and exactly what forced `crm-lead|lead` and
142
+ // `client|crm-client` to be DELETED rather than left as decoration.
143
+ const violations = findConceptViolations(corpus({}), {
144
+ "crm-lead|lead": "resolved, but the entry was left behind",
145
+ });
146
+ expect(violations).toEqual([
147
+ expect.objectContaining({ rule: "stale-allowlist", concept: "crm-lead|lead" }),
148
+ ]);
133
149
  });
134
150
  });
135
151
 
@@ -142,7 +158,7 @@ describe("concept guard — stays green on legitimate patterns", () => {
142
158
  marketing: [role("lead", { scope: "shared" })],
143
159
  })
144
160
  );
145
- expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
161
+ expect(violations).toEqual([]);
146
162
  });
147
163
 
148
164
  it("allows sibling roles authored in ONE template to share a noun", () => {
@@ -151,7 +167,7 @@ describe("concept guard — stays green on legitimate patterns", () => {
151
167
  "blockchain-ecosystem": [role("marketing-agency"), role("paid-ads-agency")],
152
168
  })
153
169
  );
154
- expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
170
+ expect(violations).toEqual([]);
155
171
  });
156
172
 
157
173
  it("allows an identical concept reused under one semanticSlug", () => {
@@ -161,7 +177,7 @@ describe("concept guard — stays green on legitimate patterns", () => {
161
177
  marketing: [role("campaign", { semanticSlug: "marketing.campaign", scope: "shared" })],
162
178
  })
163
179
  );
164
- expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
180
+ expect(violations).toEqual([]);
165
181
  });
166
182
 
167
183
  it("does not police non-role profiles", () => {
@@ -171,6 +187,6 @@ describe("concept guard — stays green on legitimate patterns", () => {
171
187
  b: [{ slug: "meeting-note", displayName: "Meeting Note", properties: [] }],
172
188
  })
173
189
  );
174
- expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
190
+ expect(violations).toEqual([]);
175
191
  });
176
192
  });
@@ -2,9 +2,11 @@
2
2
  * Cross-template CONCEPT guard — catches vocabulary forks between templates.
3
3
  *
4
4
  * WHY THIS EXISTS (read before weakening or deleting it):
5
- * `business-developer.yaml` introduced workspace-scoped `crm-lead`/`crm-client`
6
- * for the same real-world concept that `foundation.yaml`/`crm.yaml` declare as
7
- * pod-wide shared `lead`/`client`. Nothing failed. Three reasons:
5
+ * `business-developer.yaml` once introduced workspace-scoped
6
+ * `crm-lead`/`crm-client` for the same real-world concept that
7
+ * `foundation.yaml`/`crm.yaml` declare as pod-wide shared `lead`/`client` (that
8
+ * fork has since been collapsed back onto the shared slugs). Nothing failed at
9
+ * the time. Three reasons:
8
10
  *
9
11
  * 1. `build.mjs`'s `validate()` runs PER FILE, inside the read loop, so it
10
12
  * structurally cannot see that another template already owns `lead`.
@@ -46,20 +48,15 @@ export interface ConceptViolation {
46
48
  * list cannot rot into a graveyard.
47
49
  *
48
50
  * Key = the two colliding profile slugs, sorted, joined by "|".
51
+ *
52
+ * Currently EMPTY, and that is the healthy state. The two entries this list was
53
+ * born for — `crm-lead|lead` and `client|crm-client` — were RESOLVED rather
54
+ * than kept: business-developer.yaml now re-declares the shared `lead`/`client`
55
+ * roles and carries its business-development fields as workspace-overlay
56
+ * PROPERTIES. One concept, one slug, workspace-specific fields. Rule 3 below
57
+ * would have failed CI had the entries been left behind.
49
58
  */
50
- export const DECLARED_DIVERGENCES: Record<string, string> = {
51
- // Two-tier lead vocabulary, documented at business-developer.yaml:1-16.
52
- // Foundation's `lead` is a SHARED pod-wide role: being a lead is true
53
- // everywhere. `crm-lead` is a WORKSPACE-scoped role so a person can be a lead
54
- // inside one CRM workspace without becoming a lead across the whole pod. The
55
- // tiers are intentionally separate rows; `semanticSlug: crm.lead` records the
56
- // kinship so the two are discoverable as one concept via getBySemanticSlug.
57
- "crm-lead|lead":
58
- "business-developer: workspace-tier lead, deliberately NOT pod-wide foundation `lead` (two-tier CRM design)",
59
- // Same reasoning for the post-sale side of the same lifecycle.
60
- "client|crm-client":
61
- "business-developer: workspace-tier client, deliberately NOT pod-wide foundation `client` (two-tier CRM design)",
62
- };
59
+ export const DECLARED_DIVERGENCES: Record<string, string> = {};
63
60
 
64
61
  /**
65
62
  * A profile's concept identity. `semanticSlug` is the ONE field that can say
@@ -105,7 +102,14 @@ interface ProfileRef {
105
102
  }
106
103
 
107
104
  export function findConceptViolations(
108
- templates: Record<string, WorkspaceYaml> = WORKSPACE_TEMPLATES
105
+ templates: Record<string, WorkspaceYaml> = WORKSPACE_TEMPLATES,
106
+ /**
107
+ * The declared-divergence allowlist to enforce against. Defaults to the real
108
+ * one; injectable so the escape-hatch semantics (rule 1 suppression, rule 3
109
+ * staleness) stay testable now that the shipped list is empty — previously
110
+ * those tests could only be written because a live entry happened to exist.
111
+ */
112
+ declaredDivergences: Record<string, string> = DECLARED_DIVERGENCES
109
113
  ): ConceptViolation[] {
110
114
  const violations: ConceptViolation[] = [];
111
115
  const all: ProfileRef[] = [];
@@ -143,7 +147,7 @@ export function findConceptViolations(
143
147
  if (term !== conceptTerm(effectiveSemanticSlug(b.profile))) continue;
144
148
 
145
149
  const key = pairKey(a.profile.slug, b.profile.slug);
146
- if (key in DECLARED_DIVERGENCES) {
150
+ if (key in declaredDivergences) {
147
151
  usedAllowlistKeys.add(key);
148
152
  continue;
149
153
  }
@@ -200,7 +204,7 @@ export function findConceptViolations(
200
204
  }
201
205
 
202
206
  // ── RULE 3 — the allowlist may not rot ─────────────────────────────────────
203
- for (const key of Object.keys(DECLARED_DIVERGENCES)) {
207
+ for (const key of Object.keys(declaredDivergences)) {
204
208
  if (usedAllowlistKeys.has(key)) continue;
205
209
  violations.push({
206
210
  rule: "stale-allowlist",
@@ -826,6 +826,10 @@ views:
826
826
  description: Your editorial calendar — every post on the day it publishes.
827
827
  config:
828
828
  dateField: properties.publish-date
829
+ # The scope lives in `scopeProfileIds` (UUIDs), so `query.profileSlug` is
830
+ # empty — set focusKind explicitly (a slug the adapter reads from `render`)
831
+ # so the backlog + empty-state frame for posts ("Unscheduled posts").
832
+ focusKind: post
829
833
  - name: Pillars
830
834
  slug: content-pillars
831
835
  type: list
@@ -949,6 +953,24 @@ bento:
949
953
  viewName: Content Calendar
950
954
  pos: { x: 6, y: 5, w: 6, h: 5 }
951
955
 
956
+ # Per-profile entity-detail layouts (the "template-defined default" bento,
957
+ # entities.ts:3720). A `series` opens showing its ordered curriculum, a `pillar`
958
+ # showing everything made under it — the two crafted detail surfaces. Each is a
959
+ # generic header + properties PLUS a custom cell (registered in
960
+ # registerWorkspacePersonalizationCells; `widgetType` IS the cellKey). The custom
961
+ # widgets read the entity id from CellContext, the same way entity-header does.
962
+ profileEntityBentoTemplates:
963
+ series:
964
+ blocks:
965
+ - { id: series-eb-header, kind: widget, widgetType: entity-header, pos: { x: 0, y: 0, w: 12, h: 2 } }
966
+ - { id: series-eb-props, kind: widget, widgetType: entity-properties, pos: { x: 0, y: 2, w: 5, h: 8 } }
967
+ - { id: series-eb-curriculum, kind: widget, widgetType: series-curriculum, pos: { x: 5, y: 2, w: 7, h: 8 } }
968
+ pillar:
969
+ blocks:
970
+ - { id: pillar-eb-header, kind: widget, widgetType: entity-header, pos: { x: 0, y: 0, w: 12, h: 2 } }
971
+ - { id: pillar-eb-props, kind: widget, widgetType: entity-properties, pos: { x: 0, y: 2, w: 5, h: 8 } }
972
+ - { id: pillar-eb-chapter, kind: widget, widgetType: pillar-chapter, pos: { x: 5, y: 2, w: 7, h: 8 } }
973
+
952
974
  # Editorial graph: connect posts to campaign / pillar / sponsor
953
975
  entityLinks:
954
976
  - sourceProfileSlug: post
@@ -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,22 @@ 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
466
517
 
467
518
  onboarding:
468
519
  goal: >
@@ -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