@elevasis/core 0.22.0 → 0.23.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.
Files changed (112) hide show
  1. package/dist/index.d.ts +2330 -2391
  2. package/dist/index.js +2322 -1147
  3. package/dist/knowledge/index.d.ts +702 -1136
  4. package/dist/knowledge/index.js +9 -9
  5. package/dist/organization-model/index.d.ts +2330 -2391
  6. package/dist/organization-model/index.js +2322 -1147
  7. package/dist/test-utils/index.d.ts +703 -1106
  8. package/dist/test-utils/index.js +1735 -1089
  9. package/package.json +1 -1
  10. package/src/__tests__/template-core-compatibility.test.ts +11 -79
  11. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +360 -98
  12. package/src/business/acquisition/api-schemas.test.ts +2 -2
  13. package/src/business/acquisition/api-schemas.ts +7 -9
  14. package/src/business/acquisition/build-templates.test.ts +4 -4
  15. package/src/business/acquisition/build-templates.ts +72 -30
  16. package/src/business/acquisition/crm-state-actions.test.ts +13 -11
  17. package/src/business/acquisition/types.ts +7 -3
  18. package/src/execution/engine/agent/core/types.ts +1 -1
  19. package/src/execution/engine/workflow/types.ts +2 -2
  20. package/src/knowledge/README.md +8 -7
  21. package/src/knowledge/__tests__/queries.test.ts +74 -73
  22. package/src/knowledge/format.ts +10 -9
  23. package/src/knowledge/index.ts +1 -1
  24. package/src/knowledge/published.ts +1 -1
  25. package/src/knowledge/queries.ts +26 -25
  26. package/src/organization-model/README.md +66 -26
  27. package/src/organization-model/__tests__/content-kinds-registry.test.ts +210 -0
  28. package/src/organization-model/__tests__/defaults.test.ts +72 -98
  29. package/src/organization-model/__tests__/domains/actions.test.ts +56 -0
  30. package/src/organization-model/__tests__/domains/customers.test.ts +299 -295
  31. package/src/organization-model/__tests__/domains/entities.test.ts +56 -0
  32. package/src/organization-model/__tests__/domains/goals.test.ts +493 -479
  33. package/src/organization-model/__tests__/domains/identity.test.ts +280 -279
  34. package/src/organization-model/__tests__/domains/navigation.test.ts +268 -212
  35. package/src/organization-model/__tests__/domains/offerings.test.ts +414 -419
  36. package/src/organization-model/__tests__/domains/policies.test.ts +323 -0
  37. package/src/organization-model/__tests__/domains/resource-mappings.test.ts +271 -271
  38. package/src/organization-model/__tests__/domains/resources.test.ts +159 -37
  39. package/src/organization-model/__tests__/domains/roles.test.ts +147 -86
  40. package/src/organization-model/__tests__/domains/statuses.test.ts +246 -243
  41. package/src/organization-model/__tests__/domains/systems.test.ts +67 -51
  42. package/src/organization-model/__tests__/flatten-additive-merge.test.ts +361 -0
  43. package/src/organization-model/__tests__/foundation.test.ts +74 -102
  44. package/src/organization-model/__tests__/get-resources-for-system.test.ts +144 -0
  45. package/src/organization-model/__tests__/graph.test.ts +899 -71
  46. package/src/organization-model/__tests__/knowledge.test.ts +173 -52
  47. package/src/organization-model/__tests__/lookup-helpers.test.ts +438 -0
  48. package/src/organization-model/__tests__/migration-helpers.test.ts +591 -0
  49. package/src/organization-model/__tests__/prospecting-ssot.test.ts +36 -27
  50. package/src/organization-model/__tests__/recursive-system-schema.test.ts +520 -0
  51. package/src/organization-model/__tests__/resolve.test.ts +174 -23
  52. package/src/organization-model/__tests__/schema.test.ts +291 -114
  53. package/src/organization-model/__tests__/surface-projection.test.ts +207 -97
  54. package/src/organization-model/catalogs/lead-gen.ts +144 -0
  55. package/src/organization-model/content-kinds/config.ts +36 -0
  56. package/src/organization-model/content-kinds/index.ts +74 -0
  57. package/src/organization-model/content-kinds/pipeline.ts +68 -0
  58. package/src/organization-model/content-kinds/registry.ts +44 -0
  59. package/src/organization-model/content-kinds/status.ts +71 -0
  60. package/src/organization-model/content-kinds/template.ts +83 -0
  61. package/src/organization-model/content-kinds/types.ts +117 -0
  62. package/src/organization-model/contracts.ts +13 -3
  63. package/src/organization-model/defaults.ts +488 -96
  64. package/src/organization-model/domains/actions.ts +239 -0
  65. package/src/organization-model/domains/customers.ts +78 -75
  66. package/src/organization-model/domains/entities.ts +144 -0
  67. package/src/organization-model/domains/goals.ts +83 -80
  68. package/src/organization-model/domains/knowledge.ts +74 -16
  69. package/src/organization-model/domains/navigation.ts +107 -384
  70. package/src/organization-model/domains/offerings.ts +71 -66
  71. package/src/organization-model/domains/policies.ts +102 -0
  72. package/src/organization-model/domains/projects.ts +14 -48
  73. package/src/organization-model/domains/prospecting.ts +62 -181
  74. package/src/organization-model/domains/resources.ts +81 -24
  75. package/src/organization-model/domains/roles.ts +13 -10
  76. package/src/organization-model/domains/sales.ts +10 -219
  77. package/src/organization-model/domains/shared.ts +57 -57
  78. package/src/organization-model/domains/statuses.ts +339 -130
  79. package/src/organization-model/domains/systems.ts +186 -29
  80. package/src/organization-model/foundation.ts +54 -67
  81. package/src/organization-model/graph/build.ts +682 -54
  82. package/src/organization-model/graph/link.ts +1 -1
  83. package/src/organization-model/graph/schema.ts +24 -9
  84. package/src/organization-model/graph/types.ts +20 -7
  85. package/src/organization-model/helpers.ts +231 -26
  86. package/src/organization-model/index.ts +116 -5
  87. package/src/organization-model/migration-helpers.ts +249 -0
  88. package/src/organization-model/organization-graph.mdx +16 -15
  89. package/src/organization-model/organization-model.mdx +89 -41
  90. package/src/organization-model/published.ts +120 -18
  91. package/src/organization-model/resolve.ts +117 -54
  92. package/src/organization-model/schema.ts +561 -140
  93. package/src/organization-model/surface-projection.ts +116 -122
  94. package/src/organization-model/types.ts +102 -21
  95. package/src/platform/constants/versions.ts +1 -1
  96. package/src/platform/registry/__tests__/command-view.test.ts +6 -8
  97. package/src/platform/registry/__tests__/resource-link.test.ts +13 -8
  98. package/src/platform/registry/__tests__/resource-registry.integration.test.ts +16 -31
  99. package/src/platform/registry/__tests__/resource-registry.nested-systems.test.ts +245 -0
  100. package/src/platform/registry/__tests__/resource-registry.test.ts +9 -7
  101. package/src/platform/registry/__tests__/validation.test.ts +15 -11
  102. package/src/platform/registry/resource-registry.ts +20 -8
  103. package/src/platform/registry/serialization.ts +7 -7
  104. package/src/platform/registry/types.ts +3 -3
  105. package/src/platform/registry/validation.ts +17 -15
  106. package/src/reference/_generated/contracts.md +362 -99
  107. package/src/reference/glossary.md +18 -18
  108. package/src/supabase/database.types.ts +60 -0
  109. package/src/test-utils/test-utils.test.ts +1 -6
  110. package/src/organization-model/__tests__/domains/operations.test.ts +0 -203
  111. package/src/organization-model/domains/features.ts +0 -31
  112. package/src/organization-model/domains/operations.ts +0 -85
@@ -1,41 +1,39 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { DEFAULT_ORGANIZATION_MODEL_BRANDING } from '../domains/branding'
3
- import { DEFAULT_ORGANIZATION_MODEL_SALES } from '../domains/sales'
4
- import { DEFAULT_ORGANIZATION_MODEL_PROJECTS } from '../domains/projects'
5
- import { DEFAULT_ORGANIZATION_MODEL_PROSPECTING } from '../domains/prospecting'
6
3
  import { OrganizationModelSchema } from '../schema'
7
4
 
8
- function makeFeature(id: string, path = `/${id.replaceAll('.', '/')}`) {
5
+ // Phase 4 (D8): sales, prospecting, projects, navigation top-level fields removed.
6
+ // DEFAULT_ORGANIZATION_MODEL_SALES / _PROSPECTING / _PROJECTS / OrganizationModelSalesSchema etc.
7
+ // no longer exported. makeMinimalModel no longer includes those fields.
8
+ // knowledge is now a flat Record<id, OrgKnowledgeNode> (D3) — wrapper shape removed.
9
+
10
+ function makeSystem(id: string, path = `/${id.replaceAll('.', '/')}`) {
9
11
  return {
10
12
  id,
13
+ order: 10,
11
14
  label: id,
12
15
  enabled: true,
16
+ lifecycle: 'active' as const,
13
17
  path
14
18
  }
15
19
  }
16
20
 
17
- function makeSurface(id: string, featureId?: string, featureIds: string[] = []) {
18
- return {
19
- id,
20
- label: id,
21
- path: `/${id.replaceAll('.', '/')}`,
22
- surfaceType: 'page' as const,
23
- featureId,
24
- featureIds,
25
- entityIds: [],
26
- resourceIds: [],
27
- capabilityIds: []
28
- }
29
- }
30
-
31
- function makeMinimalModel(features: unknown[] = []) {
21
+ function makeMinimalModel(systems: Record<string, unknown> = {}) {
22
+ const entityOwnerId = Object.keys(systems)[0] ?? 'dashboard'
32
23
  return {
33
24
  version: 1 as const,
34
- features,
35
25
  branding: DEFAULT_ORGANIZATION_MODEL_BRANDING,
36
- sales: DEFAULT_ORGANIZATION_MODEL_SALES,
37
- prospecting: DEFAULT_ORGANIZATION_MODEL_PROSPECTING,
38
- projects: DEFAULT_ORGANIZATION_MODEL_PROJECTS
26
+ // Phase 4: sales, prospecting, projects removed from top-level OM fields.
27
+ entities: {
28
+ 'crm.deal': { id: 'crm.deal', order: 10, label: 'Deal', ownedBySystemId: entityOwnerId },
29
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: entityOwnerId },
30
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: entityOwnerId },
31
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: entityOwnerId },
32
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: entityOwnerId },
33
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: entityOwnerId },
34
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: entityOwnerId }
35
+ },
36
+ systems
39
37
  }
40
38
  }
41
39
 
@@ -45,161 +43,340 @@ function getIssueMessages(data: unknown): string[] {
45
43
  return result.error.issues.map((issue) => issue.message)
46
44
  }
47
45
 
48
- describe('flat feature tree validation', () => {
46
+ describe('system tree validation', () => {
49
47
  it('passes with a flat list that has declared ancestors', () => {
50
- const model = makeMinimalModel([
51
- { id: 'sales', label: 'Sales', enabled: true },
52
- makeFeature('sales.crm', '/sales/crm/pipeline'),
53
- makeFeature('sales.lead-gen', '/lead-gen/lists')
54
- ])
48
+ const model = makeMinimalModel({
49
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
50
+ 'sales.crm': makeSystem('sales.crm', '/sales/crm/pipeline'),
51
+ 'sales.lead-gen': makeSystem('sales.lead-gen', '/lead-gen/lists')
52
+ })
55
53
 
56
54
  expect(() => OrganizationModelSchema.parse(model)).not.toThrow()
57
55
  })
58
56
 
59
- it('rejects duplicate feature ids', () => {
60
- const messages = getIssueMessages(makeMinimalModel([makeFeature('sales'), makeFeature('sales')]))
57
+ it('rejects duplicate system ids', () => {
58
+ // In a record, duplicate keys are impossible at the JS level.
59
+ // The schema refine checks that entry.id === mapKey, so mismatched ids are caught.
60
+ const messages = getIssueMessages(
61
+ makeMinimalModel({
62
+ sales: { id: 'NOT_sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' }
63
+ })
64
+ )
61
65
 
62
- expect(messages.some((message) => message.includes('Feature id "sales" must be unique'))).toBe(true)
66
+ expect(messages.some((message) => message.includes('Each system entry id must match its map key'))).toBe(true)
63
67
  })
64
68
 
65
- it('rejects duplicate effective feature paths when explicit and default paths collide', () => {
69
+ it('rejects duplicate effective system paths when explicit and default paths collide', () => {
66
70
  const messages = getIssueMessages(
67
- makeMinimalModel([
68
- { id: 'sales', label: 'Sales', enabled: true },
69
- makeFeature('sales.crm', '/sales/lead-gen'),
70
- { id: 'sales.lead-gen', label: 'Lead Gen', enabled: true }
71
- ])
71
+ makeMinimalModel({
72
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
73
+ 'sales.crm': makeSystem('sales.crm', '/sales/lead-gen'),
74
+ 'sales.lead-gen': { id: 'sales.lead-gen', order: 20, label: 'Lead Gen', enabled: true, lifecycle: 'active' }
75
+ })
72
76
  )
73
77
 
74
78
  expect(
75
79
  messages.some((message) =>
76
- message.includes('Feature "sales.lead-gen" effective path "/sales/lead-gen" duplicates feature "sales.crm"')
80
+ message.includes('System "sales.lead-gen" effective path "/sales/lead-gen" duplicates system "sales.crm"')
77
81
  )
78
82
  ).toBe(true)
79
83
  })
80
84
 
81
85
  it('rejects a dotted child when its immediate parent is missing', () => {
82
- const messages = getIssueMessages(makeMinimalModel([makeFeature('sales.crm.pipeline')]))
86
+ const messages = getIssueMessages(
87
+ makeMinimalModel({
88
+ 'sales.crm.pipeline': { ...makeSystem('sales.crm.pipeline', '/pipeline'), parentSystemId: 'sales.crm' }
89
+ })
90
+ )
83
91
 
84
92
  expect(messages.some((message) => message.includes('unknown parent "sales.crm"'))).toBe(true)
85
93
  })
86
94
 
87
95
  it('allows a leaf without a path so resolvers can derive a default path', () => {
88
- const result = OrganizationModelSchema.safeParse(makeMinimalModel([{ id: 'sales', label: 'Sales', enabled: true }]))
96
+ const result = OrganizationModelSchema.safeParse(
97
+ makeMinimalModel({ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' } })
98
+ )
89
99
 
90
100
  expect(result.success).toBe(true)
91
101
  })
92
102
 
93
- it('rejects an enabled container with no enabled descendants', () => {
103
+ it('rejects an active container with no active descendants', () => {
94
104
  const messages = getIssueMessages(
95
- makeMinimalModel([
96
- { id: 'sales', label: 'Sales', enabled: true },
97
- { id: 'sales.crm', label: 'CRM', enabled: false, path: '/sales/crm/pipeline' }
98
- ])
105
+ makeMinimalModel({
106
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
107
+ 'sales.crm': {
108
+ id: 'sales.crm',
109
+ order: 20,
110
+ label: 'CRM',
111
+ enabled: false,
112
+ lifecycle: 'deprecated',
113
+ path: '/sales/crm/pipeline'
114
+ }
115
+ })
99
116
  )
100
117
 
101
- expect(messages.some((message) => message.includes('has no enabled descendants'))).toBe(true)
118
+ expect(messages.some((message) => message.includes('has no active descendants'))).toBe(true)
102
119
  })
103
120
 
104
- it('keeps legacy navigation inert during the release train', () => {
105
- const model = OrganizationModelSchema.parse(
106
- makeMinimalModel([makeFeature('dashboard', '/')])
107
- )
121
+ it('navigation.sidebar defaults to empty sections when omitted', () => {
122
+ const model = OrganizationModelSchema.parse(makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }))
108
123
 
109
- expect(model.navigation.surfaces).toEqual([])
110
- expect(model.navigation.groups).toEqual([])
124
+ expect(model.navigation.sidebar.primary).toEqual({})
125
+ expect(model.navigation.sidebar.bottom).toEqual({})
126
+ expect('surfaces' in model).toBe(false)
127
+ expect('navigationGroups' in model).toBe(false)
111
128
  expect('resourceMappings' in model).toBe(false)
112
129
  })
113
130
 
114
- it('rejects an unknown navigation default surface when surfaces are explicitly empty', () => {
131
+ // Phase 4: the old model.navigation.defaultSurfaceId validation no longer exists.
132
+ // Navigation defaults are now managed at the foundation layer (createFoundationOrganizationModel).
133
+ // The following tests that exercised model.navigation.* refines are skipped with a reason.
134
+ it.skip('rejects an unknown navigation default surface when surfaces are explicitly empty (deferred — Phase 4: navigation field removed)', () => {
135
+ // Previously tested: model.navigation.defaultSurfaceId referencing a missing surface.
136
+ // The navigation domain no longer exists at the top-level OM schema level.
137
+ })
138
+
139
+ it.skip('rejects navigation group surfaceIds that reference missing surfaces (deferred — Phase 4: navigation field removed)', () => {
140
+ // Previously tested: model.navigation.groups[*].surfaceIds referencing missing surfaces.
141
+ // Navigation groups are now top-level navigationGroups Record — cross-ref validation
142
+ // is handled at the resolveOrganizationModel refine layer if/when re-added.
143
+ })
144
+
145
+ it.skip('rejects navigation surface systemIds references to missing systems (deferred — Phase 4: navigation field removed)', () => {
146
+ // Previously tested: model.navigation.surfaces[*].systemIds referencing missing systems.
147
+ // Surfaces are now top-level surfaces Record — cross-ref validation is handled separately.
148
+ })
149
+
150
+ it.skip('accepts navigation systemIds when canonical systems exist (deferred — Phase 4: navigation field removed)', () => {
151
+ // Previously tested: model.navigation.surfaces[*].systemIds with valid system refs.
152
+ // See navigation.test.ts for top-level surfaces/navigationGroups coverage.
153
+ })
154
+ })
155
+
156
+ describe('domain metadata validation', () => {
157
+ it('defaults domain metadata and knowledge domain versioning', () => {
158
+ const model = OrganizationModelSchema.parse(makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }))
159
+
160
+ expect(model.domainMetadata.knowledge).toEqual({ version: 1, lastModified: '2026-05-10' })
161
+ expect(model.domainMetadata.actions).toEqual({ version: 1, lastModified: '2026-05-10' })
162
+ expect(model.domainMetadata.entities).toEqual({ version: 1, lastModified: '2026-05-10' })
163
+ expect(model.domainMetadata.policies).toEqual({ version: 1, lastModified: '2026-05-10' })
164
+ // Phase 4 (D7): knowledge version/lastModified moved to domainMetadata.knowledge.
165
+ // model.knowledge is now a flat Record<id, OrgKnowledgeNode> — no .version or .lastModified.
166
+ expect(model.domainMetadata.knowledge.version).toBe(1)
167
+ expect(model.domainMetadata.knowledge.lastModified).toBe('2026-05-10')
168
+ })
169
+
170
+ it('rejects malformed domain lastModified dates', () => {
115
171
  const messages = getIssueMessages({
116
- ...makeMinimalModel([makeFeature('dashboard', '/')]),
117
- navigation: {
118
- defaultSurfaceId: 'missing.surface',
119
- surfaces: [],
120
- groups: []
172
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
173
+ domainMetadata: {
174
+ knowledge: { version: 1, lastModified: '05/10/2026' }
121
175
  }
122
176
  })
123
177
 
124
- expect(
125
- messages.some((message) =>
126
- message.includes('Navigation defaultSurfaceId references unknown surface "missing.surface"')
127
- )
128
- ).toBe(true)
178
+ expect(messages.some((message) => message.includes('lastModified must be an ISO date string'))).toBe(true)
129
179
  })
180
+ })
130
181
 
131
- it('rejects navigation group surfaceIds that reference missing surfaces', () => {
182
+ describe('entity validation', () => {
183
+ it('rejects entities owned by unknown systems', () => {
132
184
  const messages = getIssueMessages({
133
- ...makeMinimalModel([makeFeature('dashboard', '/')]),
134
- navigation: {
135
- surfaces: [makeSurface('dashboard.home', 'dashboard')],
136
- groups: [
137
- {
138
- id: 'primary',
139
- label: 'Primary',
140
- placement: 'primary',
141
- surfaceIds: ['missing.surface']
142
- }
143
- ]
185
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
186
+ entities: {
187
+ 'crm.deal': { id: 'crm.deal', order: 10, label: 'Deal', ownedBySystemId: 'missing.system' },
188
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
189
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
190
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
191
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
192
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
193
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
144
194
  }
145
195
  })
146
196
 
147
- expect(
148
- messages.some((message) =>
149
- message.includes('Navigation group "primary" references unknown surface "missing.surface"')
150
- )
151
- ).toBe(true)
197
+ expect(messages.some((message) => message.includes('references unknown ownedBySystemId "missing.system"'))).toBe(
198
+ true
199
+ )
152
200
  })
153
201
 
154
- it('rejects navigation surface featureId references to missing features', () => {
202
+ it('rejects entity links to unknown entities', () => {
155
203
  const messages = getIssueMessages({
156
- ...makeMinimalModel([makeFeature('dashboard', '/')]),
157
- navigation: {
158
- surfaces: [makeSurface('dashboard.home', 'missing.feature')],
159
- groups: []
204
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
205
+ entities: {
206
+ 'crm.deal': {
207
+ id: 'crm.deal',
208
+ order: 10,
209
+ label: 'Deal',
210
+ ownedBySystemId: 'dashboard',
211
+ links: [{ toEntity: 'missing.entity', kind: 'belongs-to' }]
212
+ },
213
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
214
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
215
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
216
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
217
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
218
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
160
219
  }
161
220
  })
162
221
 
163
- expect(
164
- messages.some((message) =>
165
- message.includes('Navigation surface "dashboard.home" references unknown feature "missing.feature"')
166
- )
167
- ).toBe(true)
222
+ expect(messages.some((message) => message.includes('links to unknown entity "missing.entity"'))).toBe(true)
223
+ })
224
+
225
+ // Phase 4: Prospecting.contactEntityId validation removed (prospecting domain deleted).
226
+ // The separate test for prospecting entity refs is skipped below.
227
+ it.skip('rejects prospecting contactEntityId referencing unknown entity (deferred — Phase 4: prospecting domain removed)', () => {
228
+ // Previously tested: model.prospecting.contactEntityId referencing a missing entity.
229
+ // The prospecting domain no longer exists at the top-level OM schema level.
230
+ })
231
+ })
232
+
233
+ describe('system action and policy validation', () => {
234
+ it('accepts systems that attach known actions and policies', () => {
235
+ const result = OrganizationModelSchema.safeParse({
236
+ ...makeMinimalModel({
237
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
238
+ 'sales.lead-gen': makeSystem('sales.lead-gen')
239
+ }),
240
+ systems: {
241
+ sales: {
242
+ id: 'sales',
243
+ order: 10,
244
+ label: 'Sales',
245
+ lifecycle: 'active'
246
+ },
247
+ 'sales.lead-gen': {
248
+ id: 'sales.lead-gen',
249
+ order: 20,
250
+ label: 'Lead Gen',
251
+ lifecycle: 'active',
252
+ actions: [{ actionId: 'lead-gen.company.source', intent: 'exposes' }],
253
+ policies: ['policy.lead-gen.approval']
254
+ }
255
+ },
256
+ policies: {
257
+ 'policy.lead-gen.approval': {
258
+ id: 'policy.lead-gen.approval',
259
+ order: 10,
260
+ label: 'Lead Gen Approval',
261
+ trigger: { kind: 'manual' },
262
+ actions: [{ kind: 'block' }],
263
+ appliesTo: { systemIds: ['sales.lead-gen'] }
264
+ }
265
+ }
266
+ })
267
+
268
+ expect(result.success).toBe(true)
168
269
  })
169
270
 
170
- it('rejects navigation surface featureIds references to missing features', () => {
271
+ it('rejects systems that attach unknown actions or policies', () => {
171
272
  const messages = getIssueMessages({
172
- ...makeMinimalModel([makeFeature('dashboard', '/')]),
173
- navigation: {
174
- surfaces: [makeSurface('dashboard.home', 'dashboard', ['dashboard', 'missing.feature'])],
175
- groups: []
273
+ ...makeMinimalModel({
274
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
275
+ 'sales.lead-gen': makeSystem('sales.lead-gen')
276
+ }),
277
+ systems: {
278
+ sales: {
279
+ id: 'sales',
280
+ order: 10,
281
+ label: 'Sales',
282
+ lifecycle: 'active'
283
+ },
284
+ 'sales.lead-gen': {
285
+ id: 'sales.lead-gen',
286
+ order: 20,
287
+ label: 'Lead Gen',
288
+ lifecycle: 'active',
289
+ actions: [{ actionId: 'missing.action', intent: 'exposes' }],
290
+ policies: ['missing.policy']
291
+ }
176
292
  }
177
293
  })
178
294
 
179
- expect(
180
- messages.some((message) =>
181
- message.includes('Navigation surface "dashboard.home" references unknown feature "missing.feature"')
182
- )
183
- ).toBe(true)
295
+ expect(messages.some((message) => message.includes('references unknown action "missing.action"'))).toBe(true)
296
+ expect(messages.some((message) => message.includes('references unknown policy "missing.policy"'))).toBe(true)
184
297
  })
298
+ })
299
+
300
+ describe('knowledge governs validation', () => {
301
+ function makeKnowledgeNode(overrides: Record<string, unknown> = {}) {
302
+ return {
303
+ id: 'knowledge.sales-crm-playbook',
304
+ kind: 'playbook' as const,
305
+ title: 'Sales CRM Playbook',
306
+ summary: 'How CRM work is governed.',
307
+ body: '## CRM',
308
+ updatedAt: '2026-05-10',
309
+ links: [{ target: { kind: 'system', id: 'sales.crm' } }],
310
+ ...overrides
311
+ }
312
+ }
185
313
 
186
- it('allows legacy navigation feature aliases when canonical features exist', () => {
314
+ // Phase 4 (D3): knowledge is now a flat Record<id, OrgKnowledgeNode>.
315
+ // Fixtures updated from { nodes: [...] } to { 'knowledge.id': {...node} }.
316
+
317
+ it('accepts typed knowledge links when the target exists', () => {
187
318
  const result = OrganizationModelSchema.safeParse({
188
- ...makeMinimalModel([
189
- { id: 'sales', label: 'Sales', enabled: true },
190
- makeFeature('sales.crm', '/sales/crm/pipeline'),
191
- makeFeature('sales.lead-gen', '/lead-gen/lists')
192
- ]),
193
- navigation: {
194
- defaultSurfaceId: 'crm.pipeline',
195
- surfaces: [
196
- makeSurface('crm.pipeline', 'crm', ['crm']),
197
- makeSurface('lead-gen.lists', 'lead-gen', ['lead-gen'])
198
- ],
199
- groups: []
319
+ ...makeMinimalModel({
320
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
321
+ 'sales.crm': makeSystem('sales.crm', '/crm')
322
+ }),
323
+ knowledge: {
324
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode()
200
325
  }
201
326
  })
202
327
 
203
328
  expect(result.success).toBe(true)
204
329
  })
330
+
331
+ it('rejects typed knowledge links to unknown modeled targets', () => {
332
+ const messages = getIssueMessages({
333
+ ...makeMinimalModel({
334
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
335
+ 'sales.crm': makeSystem('sales.crm', '/crm')
336
+ }),
337
+ knowledge: {
338
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
339
+ links: [{ target: { kind: 'resource', id: 'missing.workflow' } }]
340
+ })
341
+ }
342
+ })
343
+
344
+ expect(messages.some((message) => message.includes('references unknown resource target "missing.workflow"'))).toBe(
345
+ true
346
+ )
347
+ })
348
+
349
+ it('allows strategy nodes to target systems', () => {
350
+ const result = OrganizationModelSchema.safeParse({
351
+ ...makeMinimalModel({
352
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
353
+ 'sales.crm': makeSystem('sales.crm', '/crm')
354
+ }),
355
+ knowledge: {
356
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
357
+ kind: 'strategy',
358
+ links: [{ target: { kind: 'system', id: 'sales.crm' } }]
359
+ })
360
+ }
361
+ })
362
+
363
+ expect(result.success).toBe(true)
364
+ })
365
+
366
+ it('rejects incompatible knowledge kind to target kind pairings', () => {
367
+ const messages = getIssueMessages({
368
+ ...makeMinimalModel({
369
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
370
+ 'sales.crm': makeSystem('sales.crm', '/crm')
371
+ }),
372
+ knowledge: {
373
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
374
+ kind: 'playbook',
375
+ links: [{ target: { kind: 'goal', id: 'missing.goal' } }]
376
+ })
377
+ }
378
+ })
379
+
380
+ expect(messages.some((message) => message.includes('kind "playbook" cannot govern goal targets'))).toBe(true)
381
+ })
205
382
  })