@elevasis/core 0.33.0 → 0.34.1

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.
@@ -1,185 +1,218 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { DEFAULT_ORGANIZATION_MODEL_BRANDING } from '../domains/branding'
3
3
  import { resolveSystemConfig } from '../helpers'
4
- import { compileOrganizationOntology, formatOntologyId, listResolvedOntologyRecords, parseOntologyId } from '../ontology'
4
+ import {
5
+ compileOrganizationOntology,
6
+ formatOntologyId,
7
+ listResolvedOntologyRecords,
8
+ parseOntologyId
9
+ } from '../ontology'
5
10
  import { resolveOrganizationModelWithResources } from '../resolve'
6
11
  import { OrganizationModelSchema } from '../schema'
7
-
8
- // Phase 4 (D8): sales, prospecting, projects, navigation top-level fields removed.
9
- // DEFAULT_ORGANIZATION_MODEL_SALES / _PROSPECTING / _PROJECTS / OrganizationModelSalesSchema etc.
10
- // no longer exported. makeMinimalModel no longer includes those fields.
11
- // knowledge is now a flat Record<id, OrgKnowledgeNode> (D3) — wrapper shape removed.
12
-
13
- function makeSystem(id: string, path = `/${id.replaceAll('.', '/')}`) {
14
- return {
15
- id,
16
- order: 10,
17
- label: id,
18
- enabled: true,
19
- lifecycle: 'active' as const,
20
- path
21
- }
22
- }
23
-
24
- function makeMinimalModel(systems: Record<string, unknown> = {}) {
25
- const entityOwnerId = Object.keys(systems)[0] ?? 'dashboard'
26
- return {
27
- version: 1 as const,
28
- branding: DEFAULT_ORGANIZATION_MODEL_BRANDING,
29
- // Phase 4: sales, prospecting, projects removed from top-level OM fields.
30
- entities: {
31
- 'crm.deal': { id: 'crm.deal', order: 10, label: 'Deal', ownedBySystemId: entityOwnerId },
32
- 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: entityOwnerId },
33
- 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: entityOwnerId },
34
- 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: entityOwnerId },
35
- 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: entityOwnerId },
36
- 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: entityOwnerId },
37
- 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: entityOwnerId }
38
- },
39
- systems
40
- }
41
- }
42
-
43
- function getIssueMessages(data: unknown): string[] {
44
- const result = OrganizationModelSchema.safeParse(data)
45
- if (result.success) return []
46
- return result.error.issues.map((issue) => issue.message)
47
- }
48
-
49
- describe('system tree validation', () => {
50
- it('passes with a flat list that has declared ancestors', () => {
51
- const model = makeMinimalModel({
52
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
53
- 'sales.crm': makeSystem('sales.crm', '/sales/crm/pipeline'),
54
- 'sales.lead-gen': makeSystem('sales.lead-gen', '/lead-gen/lists')
55
- })
56
-
57
- expect(() => OrganizationModelSchema.parse(model)).not.toThrow()
58
- })
59
-
60
- it('rejects duplicate system ids', () => {
61
- // In a record, duplicate keys are impossible at the JS level.
62
- // The schema refine checks that entry.id === mapKey, so mismatched ids are caught.
63
- const messages = getIssueMessages(
64
- makeMinimalModel({
65
- sales: { id: 'NOT_sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' }
66
- })
67
- )
68
-
69
- expect(messages.some((message) => message.includes('Each system entry id must match its map key'))).toBe(true)
70
- })
71
-
72
- it('rejects duplicate effective system paths when explicit and default paths collide', () => {
73
- const messages = getIssueMessages(
74
- makeMinimalModel({
75
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
76
- 'sales.crm': makeSystem('sales.crm', '/sales/lead-gen'),
77
- 'sales.lead-gen': { id: 'sales.lead-gen', order: 20, label: 'Lead Gen', enabled: true, lifecycle: 'active' }
78
- })
79
- )
80
-
81
- expect(
82
- messages.some((message) =>
83
- message.includes('System "sales.lead-gen" effective path "/sales/lead-gen" duplicates system "sales.crm"')
84
- )
85
- ).toBe(true)
86
- })
87
-
88
- it('rejects a dotted child when its immediate parent is missing', () => {
89
- const messages = getIssueMessages(
90
- makeMinimalModel({
91
- 'sales.crm.pipeline': { ...makeSystem('sales.crm.pipeline', '/pipeline'), parentSystemId: 'sales.crm' }
92
- })
93
- )
94
-
95
- expect(messages.some((message) => message.includes('unknown parent "sales.crm"'))).toBe(true)
96
- })
97
-
98
- it('allows a leaf without a path so resolvers can derive a default path', () => {
99
- const result = OrganizationModelSchema.safeParse(
100
- makeMinimalModel({ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' } })
101
- )
102
-
103
- expect(result.success).toBe(true)
104
- })
105
-
106
- it('rejects an active container with no active descendants', () => {
107
- const messages = getIssueMessages(
108
- makeMinimalModel({
109
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
110
- 'sales.crm': {
111
- id: 'sales.crm',
112
- order: 20,
113
- label: 'CRM',
114
- enabled: false,
115
- lifecycle: 'deprecated',
116
- path: '/sales/crm/pipeline'
117
- }
118
- })
119
- )
120
-
121
- expect(messages.some((message) => message.includes('has no active descendants'))).toBe(true)
122
- })
123
-
124
- it('navigation.sidebar defaults to empty sections when omitted', () => {
125
- const model = OrganizationModelSchema.parse(makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }))
126
-
127
- expect(model.navigation.sidebar.primary).toEqual({})
128
- expect(model.navigation.sidebar.bottom).toEqual({})
129
- expect('surfaces' in model).toBe(false)
130
- expect('navigationGroups' in model).toBe(false)
131
- expect('resourceMappings' in model).toBe(false)
132
- })
133
-
134
- // Phase 4: the old model.navigation.defaultSurfaceId validation no longer exists.
135
- // Navigation defaults are now managed at the foundation layer (createFoundationOrganizationModel).
136
- // The following tests that exercised model.navigation.* refines are skipped with a reason.
137
- it.skip('rejects an unknown navigation default surface when surfaces are explicitly empty (deferred — Phase 4: navigation field removed)', () => {
138
- // Previously tested: model.navigation.defaultSurfaceId referencing a missing surface.
139
- // The navigation domain no longer exists at the top-level OM schema level.
140
- })
141
-
142
- it.skip('rejects navigation group surfaceIds that reference missing surfaces (deferred — Phase 4: navigation field removed)', () => {
143
- // Previously tested: model.navigation.groups[*].surfaceIds referencing missing surfaces.
144
- // Navigation groups are now top-level navigationGroups Record — cross-ref validation
145
- // is handled at the resolveOrganizationModel refine layer if/when re-added.
146
- })
147
-
148
- it.skip('rejects navigation surface systemIds references to missing systems (deferred — Phase 4: navigation field removed)', () => {
149
- // Previously tested: model.navigation.surfaces[*].systemIds referencing missing systems.
150
- // Surfaces are now top-level surfaces Record — cross-ref validation is handled separately.
151
- })
152
-
153
- it.skip('accepts navigation systemIds when canonical systems exist (deferred — Phase 4: navigation field removed)', () => {
154
- // Previously tested: model.navigation.surfaces[*].systemIds with valid system refs.
155
- // See navigation.test.ts for top-level surfaces/navigationGroups coverage.
156
- })
157
- })
158
-
12
+
13
+ // Phase 4 (D8): sales, prospecting, projects, navigation top-level fields removed.
14
+ // DEFAULT_ORGANIZATION_MODEL_SALES / _PROSPECTING / _PROJECTS / OrganizationModelSalesSchema etc.
15
+ // no longer exported. makeMinimalModel no longer includes those fields.
16
+ // knowledge is now a flat Record<id, OrgKnowledgeNode> (D3) — wrapper shape removed.
17
+
18
+ function makeSystem(id: string, path = `/${id.replaceAll('.', '/')}`) {
19
+ return {
20
+ id,
21
+ order: 10,
22
+ label: id,
23
+ enabled: true,
24
+ lifecycle: 'active' as const,
25
+ path
26
+ }
27
+ }
28
+
29
+ function makeMinimalModel(systems: Record<string, unknown> = {}) {
30
+ const entityOwnerId = Object.keys(systems)[0] ?? 'dashboard'
31
+ return {
32
+ version: 1 as const,
33
+ branding: DEFAULT_ORGANIZATION_MODEL_BRANDING,
34
+ // Phase 4: sales, prospecting, projects removed from top-level OM fields.
35
+ entities: {
36
+ 'crm.deal': { id: 'crm.deal', order: 10, label: 'Deal', ownedBySystemId: entityOwnerId },
37
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: entityOwnerId },
38
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: entityOwnerId },
39
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: entityOwnerId },
40
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: entityOwnerId },
41
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: entityOwnerId },
42
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: entityOwnerId }
43
+ },
44
+ systems
45
+ }
46
+ }
47
+
48
+ function getIssueMessages(data: unknown): string[] {
49
+ const result = OrganizationModelSchema.safeParse(data)
50
+ if (result.success) return []
51
+ return result.error.issues.map((issue) => issue.message)
52
+ }
53
+
54
+ describe('system tree validation', () => {
55
+ it('passes with a flat list that has declared ancestors', () => {
56
+ const model = makeMinimalModel({
57
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
58
+ 'sales.crm': makeSystem('sales.crm', '/sales/crm/pipeline'),
59
+ 'sales.lead-gen': makeSystem('sales.lead-gen', '/lead-gen/lists')
60
+ })
61
+
62
+ expect(() => OrganizationModelSchema.parse(model)).not.toThrow()
63
+ })
64
+
65
+ it('rejects duplicate system ids', () => {
66
+ // In a record, duplicate keys are impossible at the JS level.
67
+ // The schema refine checks that entry.id === mapKey, so mismatched ids are caught.
68
+ const messages = getIssueMessages(
69
+ makeMinimalModel({
70
+ sales: { id: 'NOT_sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' }
71
+ })
72
+ )
73
+
74
+ expect(messages.some((message) => message.includes('Each system entry id must match its map key'))).toBe(true)
75
+ })
76
+
77
+ it('rejects duplicate effective system paths when explicit and default paths collide', () => {
78
+ const messages = getIssueMessages(
79
+ makeMinimalModel({
80
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
81
+ 'sales.crm': makeSystem('sales.crm', '/sales/lead-gen'),
82
+ 'sales.lead-gen': { id: 'sales.lead-gen', order: 20, label: 'Lead Gen', enabled: true, lifecycle: 'active' }
83
+ })
84
+ )
85
+
86
+ expect(
87
+ messages.some((message) =>
88
+ message.includes('System "sales.lead-gen" effective path "/sales/lead-gen" duplicates system "sales.crm"')
89
+ )
90
+ ).toBe(true)
91
+ })
92
+
93
+ it('rejects a dotted child when its immediate parent is missing', () => {
94
+ const messages = getIssueMessages(
95
+ makeMinimalModel({
96
+ 'sales.crm.pipeline': { ...makeSystem('sales.crm.pipeline', '/pipeline'), parentSystemId: 'sales.crm' }
97
+ })
98
+ )
99
+
100
+ expect(messages.some((message) => message.includes('unknown parent "sales.crm"'))).toBe(true)
101
+ })
102
+
103
+ it('allows a leaf without a path so resolvers can derive a default path', () => {
104
+ const result = OrganizationModelSchema.safeParse(
105
+ makeMinimalModel({ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' } })
106
+ )
107
+
108
+ expect(result.success).toBe(true)
109
+ })
110
+
111
+ it('rejects an active container with no active descendants', () => {
112
+ const messages = getIssueMessages(
113
+ makeMinimalModel({
114
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
115
+ 'sales.crm': {
116
+ id: 'sales.crm',
117
+ order: 20,
118
+ label: 'CRM',
119
+ enabled: false,
120
+ lifecycle: 'deprecated',
121
+ path: '/sales/crm/pipeline'
122
+ }
123
+ })
124
+ )
125
+
126
+ expect(messages.some((message) => message.includes('has no active descendants'))).toBe(true)
127
+ })
128
+
129
+ it('navigation.sidebar defaults to empty sections when omitted', () => {
130
+ const model = OrganizationModelSchema.parse(makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }))
131
+
132
+ expect(model.navigation.sidebar.primary).toEqual({})
133
+ expect(model.navigation.sidebar.bottom).toEqual({})
134
+ expect('surfaces' in model).toBe(false)
135
+ expect('navigationGroups' in model).toBe(false)
136
+ expect('resourceMappings' in model).toBe(false)
137
+ })
138
+
139
+ // Phase 4: the old model.navigation.defaultSurfaceId validation no longer exists.
140
+ // Navigation defaults are now managed at the foundation layer (createFoundationOrganizationModel).
141
+ // The following tests that exercised model.navigation.* refines are skipped with a reason.
142
+ it.skip('rejects an unknown navigation default surface when surfaces are explicitly empty (deferred — Phase 4: navigation field removed)', () => {
143
+ // Previously tested: model.navigation.defaultSurfaceId referencing a missing surface.
144
+ // The navigation domain no longer exists at the top-level OM schema level.
145
+ })
146
+
147
+ it.skip('rejects navigation group surfaceIds that reference missing surfaces (deferred — Phase 4: navigation field removed)', () => {
148
+ // Previously tested: model.navigation.groups[*].surfaceIds referencing missing surfaces.
149
+ // Navigation groups are now top-level navigationGroups Record — cross-ref validation
150
+ // is handled at the resolveOrganizationModel refine layer if/when re-added.
151
+ })
152
+
153
+ it.skip('rejects navigation surface systemIds references to missing systems (deferred — Phase 4: navigation field removed)', () => {
154
+ // Previously tested: model.navigation.surfaces[*].systemIds referencing missing systems.
155
+ // Surfaces are now top-level surfaces Record — cross-ref validation is handled separately.
156
+ })
157
+
158
+ it.skip('accepts navigation systemIds when canonical systems exist (deferred — Phase 4: navigation field removed)', () => {
159
+ // Previously tested: model.navigation.surfaces[*].systemIds with valid system refs.
160
+ // See navigation.test.ts for top-level surfaces/navigationGroups coverage.
161
+ })
162
+ })
163
+
159
164
  describe('domain metadata validation', () => {
160
- it('defaults domain metadata and knowledge domain versioning', () => {
161
- const model = OrganizationModelSchema.parse(makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }))
162
-
163
- expect(model.domainMetadata.knowledge).toEqual({ version: 1, lastModified: '2026-05-10' })
164
- expect(model.domainMetadata.actions).toEqual({ version: 1, lastModified: '2026-05-10' })
165
- expect(model.domainMetadata.entities).toEqual({ version: 1, lastModified: '2026-05-10' })
166
- expect(model.domainMetadata.policies).toEqual({ version: 1, lastModified: '2026-05-10' })
167
- // Phase 4 (D7): knowledge version/lastModified moved to domainMetadata.knowledge.
168
- // model.knowledge is now a flat Record<id, OrgKnowledgeNode> — no .version or .lastModified.
169
- expect(model.domainMetadata.knowledge.version).toBe(1)
170
- expect(model.domainMetadata.knowledge.lastModified).toBe('2026-05-10')
171
- })
172
-
173
- it('rejects malformed domain lastModified dates', () => {
174
- const messages = getIssueMessages({
175
- ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
176
- domainMetadata: {
177
- knowledge: { version: 1, lastModified: '05/10/2026' }
178
- }
179
- })
180
-
181
- expect(messages.some((message) => message.includes('lastModified must be an ISO date string'))).toBe(true)
182
- })
165
+ it('defaults domain metadata and knowledge domain versioning', () => {
166
+ const model = OrganizationModelSchema.parse(makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }))
167
+
168
+ expect(model.domainMetadata.knowledge).toEqual({ version: 1, lastModified: '2026-05-10' })
169
+ expect(model.domainMetadata.actions).toEqual({ version: 1, lastModified: '2026-05-10' })
170
+ expect(model.domainMetadata.entities).toEqual({ version: 1, lastModified: '2026-05-10' })
171
+ expect(model.domainMetadata.policies).toEqual({ version: 1, lastModified: '2026-05-10' })
172
+ // Phase 4 (D7): knowledge version/lastModified moved to domainMetadata.knowledge.
173
+ // model.knowledge is now a flat Record<id, OrgKnowledgeNode> — no .version or .lastModified.
174
+ expect(model.domainMetadata.knowledge.version).toBe(1)
175
+ expect(model.domainMetadata.knowledge.lastModified).toBe('2026-05-10')
176
+ })
177
+
178
+ it('rejects malformed domain lastModified dates', () => {
179
+ const messages = getIssueMessages({
180
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
181
+ domainMetadata: {
182
+ knowledge: { version: 1, lastModified: '05/10/2026' }
183
+ }
184
+ })
185
+
186
+ expect(messages.some((message) => message.includes('lastModified must be an ISO date string'))).toBe(true)
187
+ })
188
+ })
189
+
190
+ describe('deployment projection validation', () => {
191
+ it('rejects old deploy governance projections that omit referenced full-model domains', () => {
192
+ const messages = getIssueMessages({
193
+ ...makeMinimalModel({
194
+ sales: {
195
+ ...makeSystem('sales', '/sales'),
196
+ responsibleRoleId: 'role-ops-lead',
197
+ drivesGoals: ['goal-qualified-pipeline-q2-2026']
198
+ }
199
+ }),
200
+ resources: {
201
+ 'lead-gen-workflow': {
202
+ id: 'lead-gen-workflow',
203
+ order: 10,
204
+ kind: 'workflow',
205
+ systemPath: 'sales',
206
+ status: 'active',
207
+ ownerRoleId: 'role-ops-lead'
208
+ }
209
+ }
210
+ })
211
+
212
+ expect(messages.some((message) => message.includes('unknown responsibleRoleId "role-ops-lead"'))).toBe(true)
213
+ expect(messages.some((message) => message.includes('unknown goal "goal-qualified-pipeline-q2-2026"'))).toBe(true)
214
+ expect(messages.some((message) => message.includes('unknown ownerRoleId "role-ops-lead"'))).toBe(true)
215
+ })
183
216
  })
184
217
 
185
218
  describe('ontology contract validation', () => {
@@ -197,9 +230,9 @@ describe('ontology contract validation', () => {
197
230
  localId: 'email',
198
231
  isGlobal: true
199
232
  })
200
- expect(formatOntologyId({ scope: 'enterprise.revenue.sales.crm', kind: 'action', localId: 'deal.update-stage' })).toBe(
201
- 'enterprise.revenue.sales.crm:action/deal.update-stage'
202
- )
233
+ expect(
234
+ formatOntologyId({ scope: 'enterprise.revenue.sales.crm', kind: 'action', localId: 'deal.update-stage' })
235
+ ).toBe('enterprise.revenue.sales.crm:action/deal.update-stage')
203
236
  expect(() => parseOntologyId('sales.crm/object/deal')).toThrow()
204
237
  expect(() => parseOntologyId('sales.crm:unknown/deal')).toThrow()
205
238
  })
@@ -314,36 +347,34 @@ describe('ontology contract validation', () => {
314
347
  })
315
348
 
316
349
  it('projects legacy entities and actions while reading catalogs from authored ontology', () => {
317
- const model = OrganizationModelSchema.parse(
318
- {
319
- ...makeMinimalModel({
320
- dashboard: {
321
- ...makeSystem('dashboard', '/'),
322
- ontology: {
323
- catalogTypes: {
324
- 'dashboard:catalog/pipeline': {
325
- id: 'dashboard:catalog/pipeline',
326
- kind: 'pipeline',
327
- appliesTo: 'dashboard:object/crm.deal',
350
+ const model = OrganizationModelSchema.parse({
351
+ ...makeMinimalModel({
352
+ dashboard: {
353
+ ...makeSystem('dashboard', '/'),
354
+ ontology: {
355
+ catalogTypes: {
356
+ 'dashboard:catalog/pipeline': {
357
+ id: 'dashboard:catalog/pipeline',
358
+ kind: 'pipeline',
359
+ appliesTo: 'dashboard:object/crm.deal',
328
360
  label: 'Pipeline',
329
- entries: {
330
- open: { label: 'Open', order: 10, semanticClass: 'open' }
331
- }
361
+ entries: {
362
+ open: { label: 'Open', order: 10, semanticClass: 'open' }
332
363
  }
333
364
  }
334
365
  }
335
366
  }
336
- }),
337
- actions: {
338
- send_reply: {
339
- id: 'send_reply',
340
- order: 10,
341
- label: 'Send Reply',
342
- affects: ['crm.deal']
343
- }
367
+ }
368
+ }),
369
+ actions: {
370
+ send_reply: {
371
+ id: 'send_reply',
372
+ order: 10,
373
+ label: 'Send Reply',
374
+ affects: ['crm.deal']
344
375
  }
345
376
  }
346
- )
377
+ })
347
378
 
348
379
  const compilation = compileOrganizationOntology(model)
349
380
 
@@ -424,9 +455,7 @@ describe('ontology contract validation', () => {
424
455
  }
425
456
  })
426
457
 
427
- expect(messages.some((message) => message.includes('Duplicate ontology ID "dashboard:object/crm.deal"'))).toBe(
428
- true
429
- )
458
+ expect(messages.some((message) => message.includes('Duplicate ontology ID "dashboard:object/crm.deal"'))).toBe(true)
430
459
  expect(messages.some((message) => message.includes('legacy.entities'))).toBe(true)
431
460
  })
432
461
 
@@ -725,76 +754,76 @@ describe('system config contract', () => {
725
754
  })
726
755
 
727
756
  describe('entity validation', () => {
728
- it('rejects entities owned by unknown systems', () => {
729
- const messages = getIssueMessages({
730
- ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
731
- entities: {
732
- 'crm.deal': { id: 'crm.deal', order: 10, label: 'Deal', ownedBySystemId: 'missing.system' },
733
- 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
734
- 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
735
- 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
736
- 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
737
- 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
738
- 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
739
- }
740
- })
741
-
742
- expect(messages.some((message) => message.includes('references unknown ownedBySystemId "missing.system"'))).toBe(
743
- true
744
- )
745
- })
746
-
747
- it('rejects entity links to unknown entities', () => {
748
- const messages = getIssueMessages({
749
- ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
750
- entities: {
751
- 'crm.deal': {
752
- id: 'crm.deal',
753
- order: 10,
754
- label: 'Deal',
755
- ownedBySystemId: 'dashboard',
756
- links: [{ toEntity: 'missing.entity', kind: 'belongs-to' }]
757
- },
758
- 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
759
- 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
760
- 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
761
- 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
762
- 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
763
- 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
764
- }
765
- })
766
-
767
- expect(messages.some((message) => message.includes('links to unknown entity "missing.entity"'))).toBe(true)
768
- })
769
-
770
- // Phase 4: Prospecting.contactEntityId validation removed (prospecting domain deleted).
771
- // The separate test for prospecting entity refs is skipped below.
772
- it.skip('rejects prospecting contactEntityId referencing unknown entity (deferred — Phase 4: prospecting domain removed)', () => {
773
- // Previously tested: model.prospecting.contactEntityId referencing a missing entity.
774
- // The prospecting domain no longer exists at the top-level OM schema level.
775
- })
776
- })
777
-
778
- describe('system action and policy validation', () => {
779
- it('accepts systems that attach known actions and policies', () => {
780
- const result = OrganizationModelSchema.safeParse({
781
- ...makeMinimalModel({
782
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
783
- 'sales.lead-gen': makeSystem('sales.lead-gen')
784
- }),
757
+ it('rejects entities owned by unknown systems', () => {
758
+ const messages = getIssueMessages({
759
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
760
+ entities: {
761
+ 'crm.deal': { id: 'crm.deal', order: 10, label: 'Deal', ownedBySystemId: 'missing.system' },
762
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
763
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
764
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
765
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
766
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
767
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
768
+ }
769
+ })
770
+
771
+ expect(messages.some((message) => message.includes('references unknown ownedBySystemId "missing.system"'))).toBe(
772
+ true
773
+ )
774
+ })
775
+
776
+ it('rejects entity links to unknown entities', () => {
777
+ const messages = getIssueMessages({
778
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
779
+ entities: {
780
+ 'crm.deal': {
781
+ id: 'crm.deal',
782
+ order: 10,
783
+ label: 'Deal',
784
+ ownedBySystemId: 'dashboard',
785
+ links: [{ toEntity: 'missing.entity', kind: 'belongs-to' }]
786
+ },
787
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
788
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
789
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
790
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
791
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
792
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
793
+ }
794
+ })
795
+
796
+ expect(messages.some((message) => message.includes('links to unknown entity "missing.entity"'))).toBe(true)
797
+ })
798
+
799
+ // Phase 4: Prospecting.contactEntityId validation removed (prospecting domain deleted).
800
+ // The separate test for prospecting entity refs is skipped below.
801
+ it.skip('rejects prospecting contactEntityId referencing unknown entity (deferred — Phase 4: prospecting domain removed)', () => {
802
+ // Previously tested: model.prospecting.contactEntityId referencing a missing entity.
803
+ // The prospecting domain no longer exists at the top-level OM schema level.
804
+ })
805
+ })
806
+
807
+ describe('system action and policy validation', () => {
808
+ it('accepts systems that attach known actions and policies', () => {
809
+ const result = OrganizationModelSchema.safeParse({
810
+ ...makeMinimalModel({
811
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
812
+ 'sales.lead-gen': makeSystem('sales.lead-gen')
813
+ }),
785
814
  systems: {
786
815
  sales: {
787
- id: 'sales',
788
- order: 10,
789
- label: 'Sales',
790
- lifecycle: 'active'
791
- },
792
- 'sales.lead-gen': {
793
- id: 'sales.lead-gen',
794
- order: 20,
795
- label: 'Lead Gen',
796
- lifecycle: 'active',
797
- actions: [{ actionId: 'lead-gen.company.source', intent: 'exposes' }],
816
+ id: 'sales',
817
+ order: 10,
818
+ label: 'Sales',
819
+ lifecycle: 'active'
820
+ },
821
+ 'sales.lead-gen': {
822
+ id: 'sales.lead-gen',
823
+ order: 20,
824
+ label: 'Lead Gen',
825
+ lifecycle: 'active',
826
+ actions: [{ actionId: 'lead-gen.company.source', intent: 'exposes' }],
798
827
  policies: ['policy.lead-gen.approval']
799
828
  }
800
829
  },
@@ -806,129 +835,129 @@ describe('system action and policy validation', () => {
806
835
  }
807
836
  },
808
837
  policies: {
809
- 'policy.lead-gen.approval': {
810
- id: 'policy.lead-gen.approval',
811
- order: 10,
812
- label: 'Lead Gen Approval',
813
- trigger: { kind: 'manual' },
814
- actions: [{ kind: 'block' }],
815
- appliesTo: { systemIds: ['sales.lead-gen'] }
816
- }
817
- }
818
- })
819
-
820
- expect(result.success).toBe(true)
821
- })
822
-
823
- it('rejects systems that attach unknown actions or policies', () => {
824
- const messages = getIssueMessages({
825
- ...makeMinimalModel({
826
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
827
- 'sales.lead-gen': makeSystem('sales.lead-gen')
828
- }),
829
- systems: {
830
- sales: {
831
- id: 'sales',
832
- order: 10,
833
- label: 'Sales',
834
- lifecycle: 'active'
835
- },
836
- 'sales.lead-gen': {
837
- id: 'sales.lead-gen',
838
- order: 20,
839
- label: 'Lead Gen',
840
- lifecycle: 'active',
841
- actions: [{ actionId: 'missing.action', intent: 'exposes' }],
842
- policies: ['missing.policy']
843
- }
844
- }
845
- })
846
-
847
- expect(messages.some((message) => message.includes('references unknown action "missing.action"'))).toBe(true)
848
- expect(messages.some((message) => message.includes('references unknown policy "missing.policy"'))).toBe(true)
849
- })
850
- })
851
-
852
- describe('knowledge governs validation', () => {
853
- function makeKnowledgeNode(overrides: Record<string, unknown> = {}) {
854
- return {
855
- id: 'knowledge.sales-crm-playbook',
856
- kind: 'playbook' as const,
857
- title: 'Sales CRM Playbook',
858
- summary: 'How CRM work is governed.',
859
- body: '## CRM',
860
- updatedAt: '2026-05-10',
861
- links: [{ target: { kind: 'system', id: 'sales.crm' } }],
862
- ...overrides
863
- }
864
- }
865
-
866
- // Phase 4 (D3): knowledge is now a flat Record<id, OrgKnowledgeNode>.
867
- // Fixtures updated from { nodes: [...] } to { 'knowledge.id': {...node} }.
868
-
869
- it('accepts typed knowledge links when the target exists', () => {
870
- const result = OrganizationModelSchema.safeParse({
871
- ...makeMinimalModel({
872
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
873
- 'sales.crm': makeSystem('sales.crm', '/crm')
874
- }),
875
- knowledge: {
876
- 'knowledge.sales-crm-playbook': makeKnowledgeNode()
877
- }
878
- })
879
-
880
- expect(result.success).toBe(true)
881
- })
882
-
883
- it('rejects typed knowledge links to unknown modeled targets', () => {
884
- const messages = getIssueMessages({
885
- ...makeMinimalModel({
886
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
887
- 'sales.crm': makeSystem('sales.crm', '/crm')
888
- }),
889
- knowledge: {
890
- 'knowledge.sales-crm-playbook': makeKnowledgeNode({
891
- links: [{ target: { kind: 'resource', id: 'missing.workflow' } }]
892
- })
893
- }
894
- })
895
-
896
- expect(messages.some((message) => message.includes('references unknown resource target "missing.workflow"'))).toBe(
897
- true
898
- )
899
- })
900
-
901
- it('allows strategy nodes to target systems', () => {
902
- const result = OrganizationModelSchema.safeParse({
903
- ...makeMinimalModel({
904
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
905
- 'sales.crm': makeSystem('sales.crm', '/crm')
906
- }),
907
- knowledge: {
908
- 'knowledge.sales-crm-playbook': makeKnowledgeNode({
909
- kind: 'strategy',
910
- links: [{ target: { kind: 'system', id: 'sales.crm' } }]
911
- })
912
- }
913
- })
914
-
915
- expect(result.success).toBe(true)
916
- })
917
-
918
- it('rejects incompatible knowledge kind to target kind pairings', () => {
919
- const messages = getIssueMessages({
920
- ...makeMinimalModel({
921
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
922
- 'sales.crm': makeSystem('sales.crm', '/crm')
923
- }),
924
- knowledge: {
925
- 'knowledge.sales-crm-playbook': makeKnowledgeNode({
926
- kind: 'playbook',
927
- links: [{ target: { kind: 'goal', id: 'missing.goal' } }]
928
- })
929
- }
930
- })
931
-
932
- expect(messages.some((message) => message.includes('kind "playbook" cannot govern goal targets'))).toBe(true)
933
- })
934
- })
838
+ 'policy.lead-gen.approval': {
839
+ id: 'policy.lead-gen.approval',
840
+ order: 10,
841
+ label: 'Lead Gen Approval',
842
+ trigger: { kind: 'manual' },
843
+ actions: [{ kind: 'block' }],
844
+ appliesTo: { systemIds: ['sales.lead-gen'] }
845
+ }
846
+ }
847
+ })
848
+
849
+ expect(result.success).toBe(true)
850
+ })
851
+
852
+ it('rejects systems that attach unknown actions or policies', () => {
853
+ const messages = getIssueMessages({
854
+ ...makeMinimalModel({
855
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
856
+ 'sales.lead-gen': makeSystem('sales.lead-gen')
857
+ }),
858
+ systems: {
859
+ sales: {
860
+ id: 'sales',
861
+ order: 10,
862
+ label: 'Sales',
863
+ lifecycle: 'active'
864
+ },
865
+ 'sales.lead-gen': {
866
+ id: 'sales.lead-gen',
867
+ order: 20,
868
+ label: 'Lead Gen',
869
+ lifecycle: 'active',
870
+ actions: [{ actionId: 'missing.action', intent: 'exposes' }],
871
+ policies: ['missing.policy']
872
+ }
873
+ }
874
+ })
875
+
876
+ expect(messages.some((message) => message.includes('references unknown action "missing.action"'))).toBe(true)
877
+ expect(messages.some((message) => message.includes('references unknown policy "missing.policy"'))).toBe(true)
878
+ })
879
+ })
880
+
881
+ describe('knowledge governs validation', () => {
882
+ function makeKnowledgeNode(overrides: Record<string, unknown> = {}) {
883
+ return {
884
+ id: 'knowledge.sales-crm-playbook',
885
+ kind: 'playbook' as const,
886
+ title: 'Sales CRM Playbook',
887
+ summary: 'How CRM work is governed.',
888
+ body: '## CRM',
889
+ updatedAt: '2026-05-10',
890
+ links: [{ target: { kind: 'system', id: 'sales.crm' } }],
891
+ ...overrides
892
+ }
893
+ }
894
+
895
+ // Phase 4 (D3): knowledge is now a flat Record<id, OrgKnowledgeNode>.
896
+ // Fixtures updated from { nodes: [...] } to { 'knowledge.id': {...node} }.
897
+
898
+ it('accepts typed knowledge links when the target exists', () => {
899
+ const result = OrganizationModelSchema.safeParse({
900
+ ...makeMinimalModel({
901
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
902
+ 'sales.crm': makeSystem('sales.crm', '/crm')
903
+ }),
904
+ knowledge: {
905
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode()
906
+ }
907
+ })
908
+
909
+ expect(result.success).toBe(true)
910
+ })
911
+
912
+ it('rejects typed knowledge links to unknown modeled targets', () => {
913
+ const messages = getIssueMessages({
914
+ ...makeMinimalModel({
915
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
916
+ 'sales.crm': makeSystem('sales.crm', '/crm')
917
+ }),
918
+ knowledge: {
919
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
920
+ links: [{ target: { kind: 'resource', id: 'missing.workflow' } }]
921
+ })
922
+ }
923
+ })
924
+
925
+ expect(messages.some((message) => message.includes('references unknown resource target "missing.workflow"'))).toBe(
926
+ true
927
+ )
928
+ })
929
+
930
+ it('allows strategy nodes to target systems', () => {
931
+ const result = OrganizationModelSchema.safeParse({
932
+ ...makeMinimalModel({
933
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
934
+ 'sales.crm': makeSystem('sales.crm', '/crm')
935
+ }),
936
+ knowledge: {
937
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
938
+ kind: 'strategy',
939
+ links: [{ target: { kind: 'system', id: 'sales.crm' } }]
940
+ })
941
+ }
942
+ })
943
+
944
+ expect(result.success).toBe(true)
945
+ })
946
+
947
+ it('rejects incompatible knowledge kind to target kind pairings', () => {
948
+ const messages = getIssueMessages({
949
+ ...makeMinimalModel({
950
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
951
+ 'sales.crm': makeSystem('sales.crm', '/crm')
952
+ }),
953
+ knowledge: {
954
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
955
+ kind: 'playbook',
956
+ links: [{ target: { kind: 'goal', id: 'missing.goal' } }]
957
+ })
958
+ }
959
+ })
960
+
961
+ expect(messages.some((message) => message.includes('kind "playbook" cannot govern goal targets'))).toBe(true)
962
+ })
963
+ })