@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.
- package/dist/index.d.ts +137 -44
- package/dist/index.js +1816 -128
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/default-relation-defs.json +208 -0
- package/src/__fixtures__/report-automation-flow.json +393 -0
- package/src/agent-fleet.yaml +6 -1
- package/src/base.template.test.ts +160 -0
- package/src/base.yaml +839 -0
- package/src/business-developer.template.test.ts +79 -10
- package/src/business-developer.yaml +61 -39
- package/src/concept-guard.test.ts +47 -31
- package/src/concept-guard.ts +23 -19
- package/src/content-os.yaml +22 -0
- package/src/corpus-flow-validity.test.ts +130 -0
- package/src/crm.yaml +125 -8
- package/src/define.primary-surface.test.ts +2 -4
- package/src/define.ts +28 -0
- package/src/ecosystem.yaml +14 -0
- package/src/enterprise-os.yaml +1 -1
- package/src/foundation.yaml +8 -0
- package/src/grants.yaml +8 -0
- package/src/life-os.yaml +6 -1
- package/src/operations.yaml +270 -0
- package/src/outreach-comms.yaml +196 -0
- package/src/project-management.yaml +6 -1
- package/src/templates.ts +1717 -371
- package/src/types.ts +113 -37
- package/src/validate-flow.ts +186 -7
- package/src/validate-onboarding.test.ts +88 -0
- package/src/validate.ts +148 -12
|
@@ -47,17 +47,30 @@ describe("Business Developer workspace overlay", () => {
|
|
|
47
47
|
expect(template.workspace.visibility).toBeUndefined();
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it("
|
|
51
|
-
|
|
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("
|
|
57
|
+
expect(facet.scope).toBe("shared");
|
|
54
58
|
expect(facet.profileKind).toBe("role");
|
|
55
|
-
expect(facet.applicableKinds).toEqual(["
|
|
59
|
+
expect(facet.applicableKinds).toEqual(["company", "person"]);
|
|
60
|
+
expect(facet.semanticSlug).toBeUndefined();
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
|
|
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("
|
|
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: "
|
|
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: ["
|
|
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: ["
|
|
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: "
|
|
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
|
|
5
|
-
# `
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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 +
|
|
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
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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:
|
|
140
|
-
scope:
|
|
158
|
+
description: An active commercial relationship — created when a deal is won.
|
|
159
|
+
scope: shared
|
|
141
160
|
profileKind: role
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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: [
|
|
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: [
|
|
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("
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
expect(
|
|
53
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
190
|
+
expect(violations).toEqual([]);
|
|
175
191
|
});
|
|
176
192
|
});
|
package/src/concept-guard.ts
CHANGED
|
@@ -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
|
|
6
|
-
* for the same real-world concept that
|
|
7
|
-
* pod-wide shared `lead`/`client
|
|
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
|
|
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(
|
|
207
|
+
for (const key of Object.keys(declaredDivergences)) {
|
|
204
208
|
if (usedAllowlistKeys.has(key)) continue;
|
|
205
209
|
violations.push({
|
|
206
210
|
rule: "stale-allowlist",
|
package/src/content-os.yaml
CHANGED
|
@@ -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
|