@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.
@@ -47,17 +47,30 @@ describe("Business Developer workspace overlay", () => {
47
47
  expect(template.workspace.visibility).toBeUndefined();
48
48
  });
49
49
 
50
- it("keeps CRM lifecycle facets workspace-local", () => {
51
- for (const slug of ["crm-lead", "crm-client"]) {
50
+ it("extends the SHARED lead/client roles instead of forking them", () => {
51
+ // The lifecycle roles are the pod-wide foundation SSOT, re-declared so this
52
+ // workspace receives the grant. Row-level fields must match foundation
53
+ // VERBATIM (one row, no overlay mechanism — shared-role-ssot rule (c)); the
54
+ // business-development fields ride along as workspace-overlay PROPERTIES.
55
+ for (const slug of ["lead", "client"]) {
52
56
  const facet = profile(slug);
53
- expect(facet.scope).toBe("WORKSPACE");
57
+ expect(facet.scope).toBe("shared");
54
58
  expect(facet.profileKind).toBe("role");
55
- expect(facet.applicableKinds).toEqual(["person", "company"]);
59
+ expect(facet.applicableKinds).toEqual(["company", "person"]);
60
+ expect(facet.semanticSlug).toBeUndefined();
56
61
  }
57
62
 
58
- expect(template.profiles.some((candidate) => ["lead", "client"].includes(candidate.slug))).toBe(false);
63
+ // The forked spellings are GONE — reintroducing either is the regression.
64
+ expect(
65
+ template.profiles.some((candidate) => ["crm-lead", "crm-client"].includes(candidate.slug)),
66
+ ).toBe(false);
67
+
68
+ // Extras only: foundation's own `leadStage` / `clientStatus` / `notes` are
69
+ // NOT restated here (restating with a different definition is base drift).
70
+ expect([...propertySlugs("lead")]).not.toContain("leadStage");
71
+ expect([...propertySlugs("client")]).not.toContain("clientStatus");
59
72
 
60
- expect([...propertySlugs("crm-lead")].sort()).toEqual([
73
+ expect([...propertySlugs("lead")].sort()).toEqual([
61
74
  "assignedUserId",
62
75
  "convertedAt",
63
76
  "disqualificationReason",
@@ -70,6 +83,13 @@ describe("Business Developer workspace overlay", () => {
70
83
  "qualificationNotes",
71
84
  "source",
72
85
  ].sort());
86
+
87
+ expect([...propertySlugs("client")].sort()).toEqual([
88
+ "becameClientAt",
89
+ "handoffDestination",
90
+ "handoffNotes",
91
+ "handoffStatus",
92
+ ].sort());
73
93
  });
74
94
 
75
95
  it("models proposals and auditable commission separately from leads", () => {
@@ -136,6 +156,10 @@ describe("Business Developer workspace overlay", () => {
136
156
  });
137
157
 
138
158
  it("relies on CRM's single generic counterparty relationship", () => {
159
+ // `linked_to_deal` stays the ONE generic counterparty edge the handoff
160
+ // flow's `find-party` node reads. The role-qualified edges beside it are
161
+ // ADDITIVE (a brokered deal has a client side and a partner side) and must
162
+ // never displace it — hence the exact-order assertion.
139
163
  expect(
140
164
  crmTemplate.entityLinks?.map(({ sourceProfileSlug, targetProfileSlug, type }) => ({
141
165
  sourceProfileSlug,
@@ -145,6 +169,20 @@ describe("Business Developer workspace overlay", () => {
145
169
  ).toEqual([
146
170
  { sourceProfileSlug: "person", targetProfileSlug: "deal", type: "linked_to_deal" },
147
171
  { sourceProfileSlug: "company", targetProfileSlug: "deal", type: "linked_to_deal" },
172
+ { sourceProfileSlug: "person", targetProfileSlug: "deal", type: "client_on_deal" },
173
+ { sourceProfileSlug: "company", targetProfileSlug: "deal", type: "client_on_deal" },
174
+ { sourceProfileSlug: "person", targetProfileSlug: "deal", type: "partner_on_deal" },
175
+ { sourceProfileSlug: "company", targetProfileSlug: "deal", type: "partner_on_deal" },
176
+ ]);
177
+
178
+ // The handoff still reads ONLY the generic edge — a brokered deal's partner
179
+ // side must not be mistaken for the counterparty that becomes the client.
180
+ const handoff = template.automations?.find((a) => a.name === "Win and hand off");
181
+ const findParty = (handoff?.flow.nodes as Array<{ id?: string; data?: unknown }>).find(
182
+ (n) => n.id === "find-party",
183
+ );
184
+ expect((findParty?.data as { relationTypes?: string[] }).relationTypes).toEqual([
185
+ "linked_to_deal",
148
186
  ]);
149
187
  });
150
188
 
@@ -152,7 +190,7 @@ describe("Business Developer workspace overlay", () => {
152
190
  expect(
153
191
  template.playbooks?.map(({ name, subjectProfile }) => ({ name, profileSlug: subjectProfile?.profileSlug })),
154
192
  ).toEqual([
155
- { name: "Qualify a CRM lead", profileSlug: "crm-lead" },
193
+ { name: "Qualify a CRM lead", profileSlug: "lead" },
156
194
  { name: "Advance a commercial proposal", profileSlug: "deal" },
157
195
  ]);
158
196
  expect(template.playbooks?.[0]?.goalTemplate ?? "").toMatch(/Do not create a deal/);
@@ -215,18 +253,49 @@ describe("Business Developer workspace overlay", () => {
215
253
  }),
216
254
  );
217
255
 
256
+ // The handoff writes the SHARED role slugs — this is what makes the party
257
+ // a client pod-wide (and therefore visible to the Operations pickup).
258
+ expect(handoff?.flow.nodes).toContainEqual(
259
+ expect.objectContaining({
260
+ id: "attach-client",
261
+ data: expect.objectContaining({
262
+ outputType: "facet_attach",
263
+ config: expect.objectContaining({ facetSlug: "client" }),
264
+ }),
265
+ }),
266
+ );
267
+ expect(handoff?.flow.nodes).toContainEqual(
268
+ expect.objectContaining({
269
+ id: "convert-lead",
270
+ data: expect.objectContaining({
271
+ outputType: "facet_update",
272
+ config: expect.objectContaining({ facetSlug: "lead" }),
273
+ }),
274
+ }),
275
+ );
276
+ expect(handoff?.flow.nodes).toContainEqual(
277
+ expect.objectContaining({
278
+ id: "guard-lead",
279
+ data: expect.objectContaining({
280
+ checks: expect.arrayContaining([
281
+ expect.objectContaining({ arrayIncludes: "lead" }),
282
+ ]),
283
+ }),
284
+ }),
285
+ );
286
+
218
287
  expect(template.actionPlacements).toEqual([
219
288
  expect.objectContaining({
220
289
  profileSlug: "person",
221
290
  kind: "playbook",
222
291
  ref: "Qualify a CRM lead",
223
- when: { requiredFacetSlugs: ["crm-lead"] },
292
+ when: { requiredFacetSlugs: ["lead"] },
224
293
  }),
225
294
  expect.objectContaining({
226
295
  profileSlug: "company",
227
296
  kind: "playbook",
228
297
  ref: "Qualify a CRM lead",
229
- when: { requiredFacetSlugs: ["crm-lead"] },
298
+ when: { requiredFacetSlugs: ["lead"] },
230
299
  }),
231
300
  expect.objectContaining({
232
301
  profileSlug: "deal",
@@ -256,7 +325,7 @@ describe("Business Developer workspace overlay", () => {
256
325
  expect(playbooks).toHaveLength(2);
257
326
  expect(playbooks[0]).toMatchObject({
258
327
  name: "Qualify a CRM lead",
259
- subjectProfile: { profileSlug: "crm-lead" },
328
+ subjectProfile: { profileSlug: "lead" },
260
329
  params: [{ name: "lead", type: "string", description: "CRM lead" }],
261
330
  });
262
331
  expect(playbooks[1]).toMatchObject({
@@ -1,13 +1,19 @@
1
1
  # Business Developer — a composable, lead-first CRM operating model.
2
2
  #
3
3
  # This is an ADDITIVE overlay on the CRM workspace, not a second CRM and not a
4
- # rewrite of shared identity profiles. It deliberately uses workspace-scoped
5
- # `crm-lead` and `crm-client` role facets instead of the pod-wide `lead` and
6
- # `client` vocabulary from Foundation. A person or company can therefore be a
7
- # lead in one CRM workspace without becoming a lead everywhere in the pod.
4
+ # rewrite of shared identity profiles. It REUSES the pod-wide `lead` and
5
+ # `client` roles from Foundation (`scope: shared`, `profileKind: role`) and adds
6
+ # its business-development properties to them as WORKSPACE OVERLAYS — the
7
+ # documented extension mechanism (`getEffectiveProperties`, and rule (b) of
8
+ # `shared-role-ssot.test.ts`). It used to fork them into workspace-scoped
9
+ # `crm-lead` / `crm-client`; that was a vocabulary fork of one real-world
10
+ # concept: the same company was a "lead" under one spelling and not under the
11
+ # other, so pod-wide surfaces, the Operations handoff, and the CRM app
12
+ # disagreed about who was a client. One concept, one slug, workspace-specific
13
+ # FIELDS.
8
14
  #
9
15
  # Lifecycle:
10
- # person / company + crm-lead → optional deal(s) → won deal → crm-client
16
+ # person / company + lead → optional deal(s) → won deal → client
11
17
  #
12
18
  # Deals are commercial proposals attached through CRM's existing
13
19
  # `linked_to_deal` relation. The CRM requires one counterparty before a deal
@@ -41,23 +47,33 @@ workspace:
41
47
  - crm.business-development
42
48
 
43
49
  profiles:
44
- # Workspace-local operational status. This is intentionally separate from
45
- # Foundation's shared `lead` role: lead ownership, next actions, and CRM
46
- # lifecycle status must be visible to this CRM workspace's members only.
47
- - slug: crm-lead
48
- displayName: CRM Lead
49
- icon: user-round-search
50
- color: amber
51
- description: A person or company actively being qualified or contacted by this CRM workspace.
52
- scope: WORKSPACE
50
+ # ── Lead (reused shared role — SSOT is foundation.yaml) ───────────────────
51
+ # NOT a workspace-local copy: this RE-DECLARES foundation's pod-wide `lead`
52
+ # role so the CRM workspace receives the grant (shared profiles are
53
+ # grant-gated — a workspace only sees a shared role if it references the
54
+ # slug), and ADDS the business-development properties below.
55
+ #
56
+ # Row-level fields (displayName/icon/color/description/applicableKinds) are
57
+ # restated VERBATIM from foundation, exactly as crm.yaml does: there is ONE
58
+ # pod-wide row and no overlay mechanism for row-level fields, so a divergent
59
+ # value here would be silently false on every pod depending on apply order
60
+ # (shared-role-ssot rule (c)).
61
+ #
62
+ # The properties are EXTRAS ONLY. Foundation's own `leadStage` / `notes` are
63
+ # deliberately NOT restated — omitting is "no claim", and restating with a
64
+ # different definition is the base-drift rule (b) forbids. Extras land as
65
+ # WORKSPACE OVERLAYS via getEffectiveProperties, so this workspace's members
66
+ # see lifecycle/ownership fields that other workspaces don't.
67
+ - slug: lead
68
+ displayName: Lead
69
+ icon: user-plus
70
+ color: yellow
71
+ description: An early-pipeline prospect — not yet a client, being qualified or nurtured.
72
+ scope: shared
53
73
  profileKind: role
54
- # Explicit concept identity: this is the WORKSPACE tier of the `lead`
55
- # concept, not a second, unrelated one. Declaring it makes the two-tier
56
- # design machine-readable (both tiers are reachable via getBySemanticSlug)
57
- # and is what the cross-template concept guard requires alongside its
58
- # DECLARED_DIVERGENCES entry — see src/concept-guard.ts.
59
- semanticSlug: crm.lead
60
- applicableKinds: [person, company]
74
+ applicableKinds:
75
+ - company
76
+ - person
61
77
  properties:
62
78
  - slug: lifecycleStage
63
79
  label: Lifecycle stage
@@ -129,19 +145,22 @@ profiles:
129
145
  valueType: date
130
146
  inputType: date
131
147
 
132
- # The same counterparty receives this facet when one of its commercial
133
- # proposals is won. It intentionally carries only handoff context; client
134
- # delivery, renewal, and service health remain outside this pre-sale addon.
135
- - slug: crm-client
136
- displayName: CRM Client
137
- icon: badge-check
148
+ # ── Client (reused shared role — SSOT is foundation.yaml) ─────────────────
149
+ # Same pattern as `lead` above: foundation's pod-wide `client` role, restated
150
+ # verbatim at row level, EXTENDED with the handoff context this pre-sale
151
+ # addon owns. Client delivery, renewal, and service health belong to
152
+ # operations.yaml, which reads the SAME `client` role pod-wide — that shared
153
+ # slug is what makes the CRM → Operations handoff possible at all.
154
+ - slug: client
155
+ displayName: Client
156
+ icon: briefcase
138
157
  color: emerald
139
- description: A CRM lead converted by a won commercial proposal and ready for handoff.
140
- scope: WORKSPACE
158
+ description: An active commercial relationship — created when a deal is won.
159
+ scope: shared
141
160
  profileKind: role
142
- # Workspace tier of the `client` concept — see the note on `crm-lead` above.
143
- semanticSlug: crm.client
144
- applicableKinds: [person, company]
161
+ applicableKinds:
162
+ - company
163
+ - person
145
164
  properties:
146
165
  - slug: becameClientAt
147
166
  label: Became client at
@@ -404,7 +423,7 @@ playbooks:
404
423
  deal simply because the lead is qualified. If a commercial proposal is
405
424
  warranted, explain why and ask for or propose that next step separately.
406
425
  subjectProfile:
407
- profileSlug: crm-lead
426
+ profileSlug: lead
408
427
  params:
409
428
  - { name: lead, type: entity, label: CRM lead, required: true }
410
429
  executor: is-agent
@@ -477,8 +496,11 @@ automations:
477
496
  - path: steps.read-deal.output.entity.properties.handoffStatus
478
497
  notEquals: handed_off
479
498
  message: This proposal has already been handed off.
499
+ # minLength, NOT exists: `exists` is a null check, so an empty
500
+ # string satisfies it and the handoff would proceed with no
501
+ # sourcer actually set. The intent here is "has a value".
480
502
  - path: steps.read-deal.output.entity.properties.sourcedByUserId
481
- exists: true
503
+ minLength: 1
482
504
  message: Set the business developer who sourced this proposal before handing it off.
483
505
  - path: steps.read-deal.output.entity.properties.synapFeeAmount
484
506
  numberGte: 0
@@ -518,7 +540,7 @@ automations:
518
540
  label: Confirm qualified CRM lead
519
541
  checks:
520
542
  - path: steps.read-party.output.entity.facetSlugs
521
- arrayIncludes: crm-lead
543
+ arrayIncludes: lead
522
544
  message: The commercial counterparty must still be a CRM lead before it becomes a CRM client.
523
545
 
524
546
  # Source-deal claim is the durable idempotency boundary for the whole
@@ -699,7 +721,7 @@ automations:
699
721
  outputType: facet_attach
700
722
  config:
701
723
  entityId: "{{steps.read-party.output.entity.id}}"
702
- facetSlug: crm-client
724
+ facetSlug: client
703
725
  properties:
704
726
  becameClientAt: "{{steps.handoff-time.output.result}}"
705
727
  handoffStatus: handed_off
@@ -712,7 +734,7 @@ automations:
712
734
  outputType: facet_update
713
735
  config:
714
736
  entityId: "{{steps.read-party.output.entity.id}}"
715
- facetSlug: crm-lead
737
+ facetSlug: lead
716
738
  properties:
717
739
  lifecycleStage: converted
718
740
  convertedAt: "{{steps.handoff-time.output.result}}"
@@ -809,14 +831,14 @@ actionPlacements:
809
831
  kind: playbook
810
832
  ref: Qualify a CRM lead
811
833
  label: Qualify lead
812
- when: { requiredFacetSlugs: [crm-lead] }
834
+ when: { requiredFacetSlugs: [lead] }
813
835
 
814
836
  - profileSlug: company
815
837
  surface: entity-detail
816
838
  kind: playbook
817
839
  ref: Qualify a CRM lead
818
840
  label: Qualify lead
819
- when: { requiredFacetSlugs: [crm-lead] }
841
+ when: { requiredFacetSlugs: [lead] }
820
842
 
821
843
  - profileSlug: deal
822
844
  surface: entity-detail
@@ -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