@elevasis/core 0.26.0 → 0.27.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 +5 -5
- package/dist/index.js +209 -173
- package/dist/knowledge/index.d.ts +21 -21
- package/dist/organization-model/index.d.ts +5 -5
- package/dist/organization-model/index.js +209 -173
- package/dist/test-utils/index.d.ts +2 -2
- package/dist/test-utils/index.js +182 -126
- package/package.json +1 -1
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +976 -1063
- package/src/business/acquisition/api-schemas.test.ts +1962 -1841
- package/src/business/acquisition/api-schemas.ts +1461 -1464
- package/src/business/acquisition/crm-next-action.test.ts +45 -25
- package/src/business/acquisition/crm-next-action.ts +227 -220
- package/src/business/acquisition/crm-priority.test.ts +41 -8
- package/src/business/acquisition/crm-priority.ts +365 -349
- package/src/business/acquisition/crm-state-actions.test.ts +208 -153
- package/src/business/acquisition/derive-actions.test.ts +90 -13
- package/src/business/acquisition/derive-actions.ts +8 -139
- package/src/business/acquisition/ontology-validation.ts +72 -158
- package/src/business/pdf/sections/investment.ts +1 -1
- package/src/business/pdf/sections/summary-investment.ts +1 -1
- package/src/execution/engine/tools/tool-maps.ts +872 -831
- package/src/organization-model/__tests__/cross-ref.test.ts +167 -0
- package/src/organization-model/__tests__/published-zero-leak.test.ts +60 -1
- package/src/organization-model/__tests__/resolve.test.ts +1 -1
- package/src/organization-model/__tests__/schema-refinements.test.ts +72 -0
- package/src/organization-model/cross-ref.ts +175 -0
- package/src/organization-model/domains/branding.ts +6 -6
- package/src/organization-model/domains/sales.test.ts +104 -218
- package/src/organization-model/domains/sales.ts +212 -375
- package/src/organization-model/index.ts +1 -0
- package/src/organization-model/schema-refinements.ts +667 -0
- package/src/organization-model/schema.ts +8 -715
- package/src/reference/_generated/contracts.md +976 -1063
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { SidebarNode } from './domains/navigation'
|
|
3
|
+
import { ContractRefSchema } from './domains/resources'
|
|
4
|
+
import type { SystemEntry } from './domains/systems'
|
|
5
|
+
import type { OmTopologyNodeRef } from './domains/topology'
|
|
6
|
+
import { buildOmCrossRefIndex, knowledgeTargetExists, ONTOLOGY_REFERENCE_KEY_KINDS } from './cross-ref'
|
|
7
|
+
import { compileOrganizationOntology, listResolvedOntologyRecords, type OntologyKind } from './ontology'
|
|
8
|
+
import type { OrganizationModel } from './types'
|
|
9
|
+
|
|
10
|
+
function addIssue(ctx: z.RefinementCtx, path: Array<string | number>, message: string): void {
|
|
11
|
+
ctx.addIssue({
|
|
12
|
+
code: z.ZodIssueCode.custom,
|
|
13
|
+
path,
|
|
14
|
+
message
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isLifecycleEnabled(lifecycle: string | undefined, enabled: boolean | undefined): boolean {
|
|
19
|
+
if (enabled === false) return false
|
|
20
|
+
return lifecycle !== 'deprecated' && lifecycle !== 'archived'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function defaultSystemPathFor(id: string): string {
|
|
24
|
+
return `/${id.replaceAll('.', '/')}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asRoleHolderArray(heldBy: NonNullable<OrganizationModel['roles'][string]['heldBy']>) {
|
|
28
|
+
return Array.isArray(heldBy) ? heldBy : [heldBy]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isKnowledgeKindCompatibleWithTarget(knowledgeKind: string, targetKind: string): boolean {
|
|
32
|
+
if (knowledgeKind === 'reference') return true
|
|
33
|
+
if (knowledgeKind === 'playbook') {
|
|
34
|
+
return ['system', 'resource', 'stage', 'action', 'ontology'].includes(targetKind)
|
|
35
|
+
}
|
|
36
|
+
if (knowledgeKind === 'strategy') {
|
|
37
|
+
return ['system', 'goal', 'offering', 'customer-segment', 'ontology'].includes(targetKind)
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function refineOrganizationModel(model: OrganizationModel, ctx: z.RefinementCtx): void {
|
|
47
|
+
// Collect ALL system entries recursively — top-level systems plus any nested subsystems.
|
|
48
|
+
// Wave 2 canonical OM authors nested subsystems (e.g. sys → subsystems → 'lead-gen' with id
|
|
49
|
+
// 'sys.lead-gen'). Resource systemPath cross-refs must resolve against the full flattened set.
|
|
50
|
+
type SystemWithPath = { path: string; schemaPath: Array<string | number>; system: SystemEntry }
|
|
51
|
+
|
|
52
|
+
function collectAllSystems(
|
|
53
|
+
systems: Record<string, SystemEntry>,
|
|
54
|
+
prefix = '',
|
|
55
|
+
schemaPath: Array<string | number> = ['systems']
|
|
56
|
+
): SystemWithPath[] {
|
|
57
|
+
const result: SystemWithPath[] = []
|
|
58
|
+
for (const [key, system] of Object.entries(systems)) {
|
|
59
|
+
const path = prefix ? `${prefix}.${key}` : key
|
|
60
|
+
const currentSchemaPath = [...schemaPath, key]
|
|
61
|
+
result.push({ path, schemaPath: currentSchemaPath, system })
|
|
62
|
+
const childSystems = system.systems ?? system.subsystems
|
|
63
|
+
if (childSystems !== undefined) {
|
|
64
|
+
result.push(
|
|
65
|
+
...collectAllSystems(childSystems, path, [
|
|
66
|
+
...currentSchemaPath,
|
|
67
|
+
system.systems !== undefined ? 'systems' : 'subsystems'
|
|
68
|
+
])
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const allSystems = collectAllSystems(model.systems)
|
|
76
|
+
const systemsById = new Map<string, SystemEntry>()
|
|
77
|
+
for (const { path, system } of allSystems) {
|
|
78
|
+
systemsById.set(path, system)
|
|
79
|
+
systemsById.set(system.id, system)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const systemIdsByEffectivePath = new Map<string, string>()
|
|
83
|
+
allSystems.forEach(({ path, schemaPath, system }) => {
|
|
84
|
+
if (system.parentSystemId !== undefined && !systemsById.has(system.parentSystemId)) {
|
|
85
|
+
addIssue(
|
|
86
|
+
ctx,
|
|
87
|
+
[...schemaPath, 'parentSystemId'],
|
|
88
|
+
`System "${system.id}" references unknown parent "${system.parentSystemId}"`
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hasChildren =
|
|
93
|
+
Object.keys(system.systems ?? system.subsystems ?? {}).length > 0 ||
|
|
94
|
+
allSystems.some(
|
|
95
|
+
(candidate) => candidate.path.startsWith(`${path}.`) && !candidate.path.slice(path.length + 1).includes('.')
|
|
96
|
+
)
|
|
97
|
+
const contributesRoutePath = system.ui?.path !== undefined || system.path !== undefined || !hasChildren
|
|
98
|
+
if (contributesRoutePath) {
|
|
99
|
+
const effectivePath = system.ui?.path ?? system.path ?? defaultSystemPathFor(path)
|
|
100
|
+
const existingSystemId = systemIdsByEffectivePath.get(effectivePath)
|
|
101
|
+
if (existingSystemId !== undefined) {
|
|
102
|
+
addIssue(
|
|
103
|
+
ctx,
|
|
104
|
+
[...schemaPath, system.ui?.path !== undefined ? 'ui' : 'path'],
|
|
105
|
+
`System "${path}" effective path "${effectivePath}" duplicates system "${existingSystemId}"`
|
|
106
|
+
)
|
|
107
|
+
} else {
|
|
108
|
+
systemIdsByEffectivePath.set(effectivePath, path)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (hasChildren && isLifecycleEnabled(system.lifecycle, system.enabled)) {
|
|
113
|
+
const hasEnabledDescendant =
|
|
114
|
+
Object.values(system.systems ?? system.subsystems ?? {}).some((candidate) =>
|
|
115
|
+
isLifecycleEnabled(candidate.lifecycle, candidate.enabled)
|
|
116
|
+
) ||
|
|
117
|
+
allSystems.some(
|
|
118
|
+
(candidate) =>
|
|
119
|
+
candidate.path.startsWith(`${path}.`) &&
|
|
120
|
+
!candidate.path.slice(path.length + 1).includes('.') &&
|
|
121
|
+
isLifecycleEnabled(candidate.system.lifecycle, candidate.system.enabled)
|
|
122
|
+
)
|
|
123
|
+
if (!hasEnabledDescendant) {
|
|
124
|
+
addIssue(ctx, [...schemaPath, 'lifecycle'], `System "${path}" is active but has no active descendants`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
allSystems.forEach(({ schemaPath, system }) => {
|
|
130
|
+
const visited = new Set<string>()
|
|
131
|
+
let currentParentId = system.parentSystemId
|
|
132
|
+
|
|
133
|
+
while (currentParentId !== undefined) {
|
|
134
|
+
if (currentParentId === system.id || visited.has(currentParentId)) {
|
|
135
|
+
addIssue(ctx, [...schemaPath, 'parentSystemId'], `System "${system.id}" has a parent cycle`)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
visited.add(currentParentId)
|
|
140
|
+
currentParentId = systemsById.get(currentParentId)?.parentSystemId
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
type CollectedSidebarSurface = {
|
|
145
|
+
id: string
|
|
146
|
+
node: Extract<SidebarNode, { type: 'surface' }>
|
|
147
|
+
path: Array<string | number>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeRoutePath(path: string): string {
|
|
151
|
+
return path.length > 1 ? path.replace(/\/+$/, '') : path
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sidebarNodeIds = new Map<string, Array<string | number>>()
|
|
155
|
+
const sidebarSurfacePaths = new Map<string, string>()
|
|
156
|
+
const sidebarSurfaces: CollectedSidebarSurface[] = []
|
|
157
|
+
|
|
158
|
+
function collectSidebarNodes(nodes: Record<string, SidebarNode>, schemaPath: Array<string | number>): void {
|
|
159
|
+
Object.entries(nodes).forEach(([nodeId, node]) => {
|
|
160
|
+
const nodePath = [...schemaPath, nodeId]
|
|
161
|
+
const existingNodePath = sidebarNodeIds.get(nodeId)
|
|
162
|
+
if (existingNodePath !== undefined) {
|
|
163
|
+
addIssue(ctx, nodePath, `Sidebar node id "${nodeId}" duplicates another sidebar node`)
|
|
164
|
+
} else {
|
|
165
|
+
sidebarNodeIds.set(nodeId, nodePath)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (node.type === 'group') {
|
|
169
|
+
collectSidebarNodes(node.children, [...nodePath, 'children'])
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
sidebarSurfaces.push({ id: nodeId, node, path: nodePath })
|
|
174
|
+
const normalizedPath = normalizeRoutePath(node.path)
|
|
175
|
+
const existingSurfaceId = sidebarSurfacePaths.get(normalizedPath)
|
|
176
|
+
if (existingSurfaceId !== undefined) {
|
|
177
|
+
addIssue(
|
|
178
|
+
ctx,
|
|
179
|
+
[...nodePath, 'path'],
|
|
180
|
+
`Sidebar surface path "${node.path}" duplicates surface "${existingSurfaceId}"`
|
|
181
|
+
)
|
|
182
|
+
} else {
|
|
183
|
+
sidebarSurfacePaths.set(normalizedPath, nodeId)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
node.targets?.systems?.forEach((systemId, systemIndex) => {
|
|
187
|
+
if (!systemsById.has(systemId)) {
|
|
188
|
+
addIssue(
|
|
189
|
+
ctx,
|
|
190
|
+
[...nodePath, 'targets', 'systems', systemIndex],
|
|
191
|
+
`Sidebar surface "${nodeId}" references unknown system "${systemId}"`
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
collectSidebarNodes(model.navigation.sidebar.primary, ['navigation', 'sidebar', 'primary'])
|
|
199
|
+
collectSidebarNodes(model.navigation.sidebar.bottom, ['navigation', 'sidebar', 'bottom'])
|
|
200
|
+
|
|
201
|
+
// Offerings -> CustomerSegment cross-ref: targetSegmentIds must resolve
|
|
202
|
+
const segmentsById = new Map(Object.entries(model.customers))
|
|
203
|
+
Object.values(model.offerings).forEach((product) => {
|
|
204
|
+
product.targetSegmentIds.forEach((segmentId, segmentIndex) => {
|
|
205
|
+
if (!segmentsById.has(segmentId)) {
|
|
206
|
+
addIssue(
|
|
207
|
+
ctx,
|
|
208
|
+
['offerings', product.id, 'targetSegmentIds', segmentIndex],
|
|
209
|
+
`Product "${product.id}" references unknown customer segment "${segmentId}"`
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// Offerings -> System cross-ref: deliveryFeatureId must resolve (when present)
|
|
215
|
+
if (product.deliveryFeatureId !== undefined && !systemsById.has(product.deliveryFeatureId)) {
|
|
216
|
+
addIssue(
|
|
217
|
+
ctx,
|
|
218
|
+
['offerings', product.id, 'deliveryFeatureId'],
|
|
219
|
+
`Product "${product.id}" references unknown delivery system "${product.deliveryFeatureId}"`
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Goals -> period-range validation: periodEnd must be strictly after periodStart
|
|
225
|
+
Object.values(model.goals).forEach((objective) => {
|
|
226
|
+
if (objective.periodEnd <= objective.periodStart) {
|
|
227
|
+
addIssue(
|
|
228
|
+
ctx,
|
|
229
|
+
['goals', objective.id, 'periodEnd'],
|
|
230
|
+
`Goal "${objective.id}" has periodEnd "${objective.periodEnd}" which must be strictly after periodStart "${objective.periodStart}"`
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const goalsById = new Map(Object.entries(model.goals))
|
|
236
|
+
// Phase 4: knowledge is now a flat Record<id, OrgKnowledgeNode> — no .nodes array
|
|
237
|
+
const knowledgeById = new Map(Object.entries(model.knowledge))
|
|
238
|
+
const actionsById = new Map(Object.entries(model.actions))
|
|
239
|
+
const entitiesById = new Map(Object.entries(model.entities))
|
|
240
|
+
const policiesById = new Map(Object.entries(model.policies))
|
|
241
|
+
|
|
242
|
+
sidebarSurfaces.forEach(({ id, node, path }) => {
|
|
243
|
+
node.targets?.entities?.forEach((entityId, entityIndex) => {
|
|
244
|
+
if (!entitiesById.has(entityId)) {
|
|
245
|
+
addIssue(
|
|
246
|
+
ctx,
|
|
247
|
+
[...path, 'targets', 'entities', entityIndex],
|
|
248
|
+
`Sidebar surface "${id}" references unknown entity "${entityId}"`
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
node.targets?.actions?.forEach((actionId, actionIndex) => {
|
|
254
|
+
if (!actionsById.has(actionId)) {
|
|
255
|
+
addIssue(
|
|
256
|
+
ctx,
|
|
257
|
+
[...path, 'targets', 'actions', actionIndex],
|
|
258
|
+
`Sidebar surface "${id}" references unknown action "${actionId}"`
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// An empty `systems` map is the documented "no tenant model yet" shell
|
|
265
|
+
// sentinel (generic core ships empty systems; tenant/canonical models supply
|
|
266
|
+
// their own). Validating entity ownership refs against an intentionally-empty
|
|
267
|
+
// systems map is meaningless, so only enforce when systems are declared.
|
|
268
|
+
Object.values(model.entities).forEach((entity) => {
|
|
269
|
+
if (systemsById.size > 0 && !systemsById.has(entity.ownedBySystemId)) {
|
|
270
|
+
addIssue(
|
|
271
|
+
ctx,
|
|
272
|
+
['entities', entity.id, 'ownedBySystemId'],
|
|
273
|
+
`Entity "${entity.id}" references unknown ownedBySystemId "${entity.ownedBySystemId}"`
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
entity.links?.forEach((link, linkIndex) => {
|
|
278
|
+
if (!entitiesById.has(link.toEntity)) {
|
|
279
|
+
addIssue(
|
|
280
|
+
ctx,
|
|
281
|
+
['entities', entity.id, 'links', linkIndex, 'toEntity'],
|
|
282
|
+
`Entity "${entity.id}" links to unknown entity "${link.toEntity}"`
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// Roles -> reportsToId cross-ref: each reportsToId must resolve to another role in the same collection
|
|
289
|
+
const rolesById = new Map(Object.entries(model.roles))
|
|
290
|
+
Object.values(model.roles).forEach((role) => {
|
|
291
|
+
if (role.reportsToId !== undefined && !rolesById.has(role.reportsToId)) {
|
|
292
|
+
addIssue(
|
|
293
|
+
ctx,
|
|
294
|
+
['roles', role.id, 'reportsToId'],
|
|
295
|
+
`Role "${role.id}" references unknown reportsToId "${role.reportsToId}"`
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
Object.values(model.roles).forEach((role) => {
|
|
301
|
+
const visited = new Set<string>()
|
|
302
|
+
let currentReportsToId = role.reportsToId
|
|
303
|
+
|
|
304
|
+
while (currentReportsToId !== undefined) {
|
|
305
|
+
if (currentReportsToId === role.id || visited.has(currentReportsToId)) {
|
|
306
|
+
addIssue(ctx, ['roles', role.id, 'reportsToId'], `Role "${role.id}" has a reportsToId cycle`)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
visited.add(currentReportsToId)
|
|
311
|
+
currentReportsToId = rolesById.get(currentReportsToId)?.reportsToId
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
Object.values(model.roles).forEach((role) => {
|
|
316
|
+
role.responsibleFor?.forEach((systemId, systemIndex) => {
|
|
317
|
+
if (!systemsById.has(systemId)) {
|
|
318
|
+
addIssue(
|
|
319
|
+
ctx,
|
|
320
|
+
['roles', role.id, 'responsibleFor', systemIndex],
|
|
321
|
+
`Role "${role.id}" references unknown responsibleFor system "${systemId}"`
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
allSystems.forEach(({ schemaPath, system }) => {
|
|
328
|
+
if (system.responsibleRoleId !== undefined && !rolesById.has(system.responsibleRoleId)) {
|
|
329
|
+
addIssue(
|
|
330
|
+
ctx,
|
|
331
|
+
[...schemaPath, 'responsibleRoleId'],
|
|
332
|
+
`System "${system.id}" references unknown responsibleRoleId "${system.responsibleRoleId}"`
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
system.governedByKnowledge?.forEach((nodeId, nodeIndex) => {
|
|
337
|
+
if (!knowledgeById.has(nodeId)) {
|
|
338
|
+
addIssue(
|
|
339
|
+
ctx,
|
|
340
|
+
[...schemaPath, 'governedByKnowledge', nodeIndex],
|
|
341
|
+
`System "${system.id}" references unknown knowledge node "${nodeId}"`
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
system.drivesGoals?.forEach((goalId, goalIndex) => {
|
|
347
|
+
if (!goalsById.has(goalId)) {
|
|
348
|
+
addIssue(
|
|
349
|
+
ctx,
|
|
350
|
+
[...schemaPath, 'drivesGoals', goalIndex],
|
|
351
|
+
`System "${system.id}" references unknown goal "${goalId}"`
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
system.actions?.forEach((actionRef, actionIndex) => {
|
|
357
|
+
if (!actionsById.has(actionRef.actionId)) {
|
|
358
|
+
addIssue(
|
|
359
|
+
ctx,
|
|
360
|
+
[...schemaPath, 'actions', actionIndex, 'actionId'],
|
|
361
|
+
`System "${system.id}" references unknown action "${actionRef.actionId}"`
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
system.policies?.forEach((policyId, policyIndex) => {
|
|
367
|
+
if (!policiesById.has(policyId)) {
|
|
368
|
+
addIssue(
|
|
369
|
+
ctx,
|
|
370
|
+
[...schemaPath, 'policies', policyIndex],
|
|
371
|
+
`System "${system.id}" references unknown policy "${policyId}"`
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
Object.values(model.actions).forEach((action) => {
|
|
378
|
+
action.affects?.forEach((entityId, entityIndex) => {
|
|
379
|
+
if (!entitiesById.has(entityId)) {
|
|
380
|
+
addIssue(
|
|
381
|
+
ctx,
|
|
382
|
+
['actions', action.id, 'affects', entityIndex],
|
|
383
|
+
`Action "${action.id}" affects unknown entity "${entityId}"`
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// Phase 4: sales / prospecting / projects compound-domain entity cross-ref checks removed.
|
|
390
|
+
// Those entity bindings now live in System.ontology catalog scopes.
|
|
391
|
+
|
|
392
|
+
const resourcesById = new Map(Object.entries(model.resources))
|
|
393
|
+
sidebarSurfaces.forEach(({ id, node, path }) => {
|
|
394
|
+
node.targets?.resources?.forEach((resourceId, resourceIndex) => {
|
|
395
|
+
if (!resourcesById.has(resourceId)) {
|
|
396
|
+
addIssue(
|
|
397
|
+
ctx,
|
|
398
|
+
[...path, 'targets', 'resources', resourceIndex],
|
|
399
|
+
`Sidebar surface "${id}" references unknown resource "${resourceId}"`
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
// Shared cross-reference index — single source of truth for (kind, id) resolution.
|
|
405
|
+
const idx = buildOmCrossRefIndex(model)
|
|
406
|
+
const { ontologyIndexByKind, ontologyIds } = idx
|
|
407
|
+
// ontologyCompilation retained locally for listResolvedOntologyRecords and diagnostics.
|
|
408
|
+
const ontologyCompilation = compileOrganizationOntology(model)
|
|
409
|
+
|
|
410
|
+
function topologyTargetExists(ref: OmTopologyNodeRef): boolean {
|
|
411
|
+
if (ref.kind === 'system') return systemsById.has(ref.id)
|
|
412
|
+
if (ref.kind === 'resource') return resourcesById.has(ref.id)
|
|
413
|
+
if (ref.kind === 'ontology') return ontologyIds.has(ref.id)
|
|
414
|
+
if (ref.kind === 'policy') return policiesById.has(ref.id)
|
|
415
|
+
if (ref.kind === 'role') return rolesById.has(ref.id)
|
|
416
|
+
|
|
417
|
+
// Trigger, human checkpoint, and external resource refs are projected
|
|
418
|
+
// topology nodes during the bridge period; their owning runtime indexes are
|
|
419
|
+
// validated by deployment projection in later waves.
|
|
420
|
+
return true
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
Object.entries(model.topology.relationships).forEach(([relationshipId, relationship]) => {
|
|
424
|
+
;(['from', 'to'] as const).forEach((side) => {
|
|
425
|
+
const ref = relationship[side]
|
|
426
|
+
if (topologyTargetExists(ref)) return
|
|
427
|
+
|
|
428
|
+
addIssue(
|
|
429
|
+
ctx,
|
|
430
|
+
['topology', 'relationships', relationshipId, side],
|
|
431
|
+
`Topology relationship "${relationshipId}" ${side} references unknown ${ref.kind} "${ref.id}"`
|
|
432
|
+
)
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
function validateKnownOntologyReferences(
|
|
437
|
+
ownerId: string,
|
|
438
|
+
value: unknown,
|
|
439
|
+
path: Array<string | number>,
|
|
440
|
+
seen = new WeakSet<object>()
|
|
441
|
+
): void {
|
|
442
|
+
if (Array.isArray(value)) {
|
|
443
|
+
value.forEach((entry, index) => validateKnownOntologyReferences(ownerId, entry, [...path, index], seen))
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!isRecord(value)) return
|
|
448
|
+
if (seen.has(value)) return
|
|
449
|
+
seen.add(value)
|
|
450
|
+
|
|
451
|
+
Object.entries(value).forEach(([key, entry]) => {
|
|
452
|
+
const expectedKind = ONTOLOGY_REFERENCE_KEY_KINDS[key]
|
|
453
|
+
if (expectedKind !== undefined) {
|
|
454
|
+
if (typeof entry !== 'string') {
|
|
455
|
+
addIssue(ctx, [...path, key], `Ontology record "${ownerId}" ${key} must be an ontology ID string`)
|
|
456
|
+
} else if (ontologyIndexByKind[expectedKind][entry] === undefined) {
|
|
457
|
+
addIssue(
|
|
458
|
+
ctx,
|
|
459
|
+
[...path, key],
|
|
460
|
+
`Ontology record "${ownerId}" ${key} references unknown ${expectedKind} ontology ID "${entry}"`
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
validateKnownOntologyReferences(ownerId, entry, [...path, key], seen)
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (const { id, record } of listResolvedOntologyRecords(ontologyCompilation.ontology)) {
|
|
470
|
+
validateKnownOntologyReferences(id, record, record.origin.path)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
Object.values(model.policies).forEach((policy) => {
|
|
474
|
+
policy.appliesTo.systemIds.forEach((systemId, systemIndex) => {
|
|
475
|
+
if (!systemsById.has(systemId)) {
|
|
476
|
+
addIssue(
|
|
477
|
+
ctx,
|
|
478
|
+
['policies', policy.id, 'appliesTo', 'systemIds', systemIndex],
|
|
479
|
+
`Policy "${policy.id}" applies to unknown system "${systemId}"`
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
policy.appliesTo.actionIds.forEach((actionId, actionIndex) => {
|
|
485
|
+
if (!actionsById.has(actionId)) {
|
|
486
|
+
addIssue(
|
|
487
|
+
ctx,
|
|
488
|
+
['policies', policy.id, 'appliesTo', 'actionIds', actionIndex],
|
|
489
|
+
`Policy "${policy.id}" applies to unknown action "${actionId}"`
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
policy.actions.forEach((action, actionIndex) => {
|
|
495
|
+
if (action.kind === 'invoke-action' && !actionsById.has(action.actionId)) {
|
|
496
|
+
addIssue(
|
|
497
|
+
ctx,
|
|
498
|
+
['policies', policy.id, 'actions', actionIndex, 'actionId'],
|
|
499
|
+
`Policy "${policy.id}" invokes unknown action "${action.actionId}"`
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
if (
|
|
503
|
+
(action.kind === 'notify-role' || action.kind === 'require-approval') &&
|
|
504
|
+
action.roleId !== undefined &&
|
|
505
|
+
!rolesById.has(action.roleId)
|
|
506
|
+
) {
|
|
507
|
+
addIssue(
|
|
508
|
+
ctx,
|
|
509
|
+
['policies', policy.id, 'actions', actionIndex, 'roleId'],
|
|
510
|
+
`Policy "${policy.id}" references unknown role "${action.roleId}"`
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
if (policy.trigger.kind === 'action-invocation' && !actionsById.has(policy.trigger.actionId)) {
|
|
516
|
+
addIssue(
|
|
517
|
+
ctx,
|
|
518
|
+
['policies', policy.id, 'trigger', 'actionId'],
|
|
519
|
+
`Policy "${policy.id}" references unknown trigger action "${policy.trigger.actionId}"`
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// Phase 4: model.knowledge is now Record<id, OrgKnowledgeNode> — iterate Object.values
|
|
525
|
+
Object.entries(model.knowledge).forEach(([nodeId, node]) => {
|
|
526
|
+
node.links.forEach((link, linkIndex) => {
|
|
527
|
+
if (!knowledgeTargetExists(idx, link.target.kind, link.target.id)) {
|
|
528
|
+
addIssue(
|
|
529
|
+
ctx,
|
|
530
|
+
['knowledge', nodeId, 'links', linkIndex, 'target'],
|
|
531
|
+
`Knowledge node "${node.id}" references unknown ${link.target.kind} target "${link.target.id}"`
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (!isKnowledgeKindCompatibleWithTarget(node.kind, link.target.kind)) {
|
|
536
|
+
addIssue(
|
|
537
|
+
ctx,
|
|
538
|
+
['knowledge', nodeId, 'links', linkIndex, 'target', 'kind'],
|
|
539
|
+
`Knowledge node "${node.id}" kind "${node.kind}" cannot govern ${link.target.kind} targets`
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// `governedByKnowledge` is validated one-way on target nodes above. Knowledge
|
|
544
|
+
// links may be authored first and remain valid as forward references.
|
|
545
|
+
})
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
Object.values(model.resources).forEach((resource) => {
|
|
549
|
+
if (!systemsById.has(resource.systemPath)) {
|
|
550
|
+
addIssue(
|
|
551
|
+
ctx,
|
|
552
|
+
['resources', resource.id, 'systemPath'],
|
|
553
|
+
`Resource "${resource.id}" references unknown system path "${resource.systemPath}"`
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (resource.ownerRoleId !== undefined && !rolesById.has(resource.ownerRoleId)) {
|
|
558
|
+
addIssue(
|
|
559
|
+
ctx,
|
|
560
|
+
['resources', resource.id, 'ownerRoleId'],
|
|
561
|
+
`Resource "${resource.id}" references unknown ownerRoleId "${resource.ownerRoleId}"`
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (resource.kind === 'agent' && resource.actsAsRoleId !== undefined && !rolesById.has(resource.actsAsRoleId)) {
|
|
566
|
+
addIssue(
|
|
567
|
+
ctx,
|
|
568
|
+
['resources', resource.id, 'actsAsRoleId'],
|
|
569
|
+
`Agent resource "${resource.id}" references unknown actsAsRoleId "${resource.actsAsRoleId}"`
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
function validateResourceOntologyBinding(
|
|
575
|
+
resourceId: string,
|
|
576
|
+
bindingKey: 'actions' | 'primaryAction' | 'reads' | 'writes' | 'usesCatalogs' | 'emits',
|
|
577
|
+
expectedKind: OntologyKind,
|
|
578
|
+
ids: string[] | string | undefined
|
|
579
|
+
): void {
|
|
580
|
+
const ontologyIds = ids === undefined ? [] : Array.isArray(ids) ? ids : [ids]
|
|
581
|
+
|
|
582
|
+
ontologyIds.forEach((ontologyId, ontologyIndex) => {
|
|
583
|
+
if (ontologyIndexByKind[expectedKind][ontologyId] === undefined) {
|
|
584
|
+
addIssue(
|
|
585
|
+
ctx,
|
|
586
|
+
['resources', resourceId, 'ontology', bindingKey, ...(Array.isArray(ids) ? [ontologyIndex] : [])],
|
|
587
|
+
`Resource "${resourceId}" ontology binding "${bindingKey}" references unknown ${expectedKind} ontology ID "${ontologyId}"`
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
Object.values(model.resources).forEach((resource) => {
|
|
594
|
+
const binding = resource.ontology
|
|
595
|
+
if (binding === undefined) return
|
|
596
|
+
|
|
597
|
+
validateResourceOntologyBinding(resource.id, 'actions', 'action', binding.actions)
|
|
598
|
+
validateResourceOntologyBinding(resource.id, 'primaryAction', 'action', binding.primaryAction)
|
|
599
|
+
validateResourceOntologyBinding(resource.id, 'reads', 'object', binding.reads)
|
|
600
|
+
validateResourceOntologyBinding(resource.id, 'writes', 'object', binding.writes)
|
|
601
|
+
validateResourceOntologyBinding(resource.id, 'usesCatalogs', 'catalog', binding.usesCatalogs)
|
|
602
|
+
validateResourceOntologyBinding(resource.id, 'emits', 'event', binding.emits)
|
|
603
|
+
|
|
604
|
+
// Tier-1: validate contract ref SHAPE only — no module resolution (browser-safe).
|
|
605
|
+
// Tier-2 intra-package resolution runs in om:verify (packages/cli/src/knowledge/verify.ts).
|
|
606
|
+
if (binding.contract !== undefined) {
|
|
607
|
+
const contractEntries = [
|
|
608
|
+
['input', binding.contract.input],
|
|
609
|
+
['output', binding.contract.output]
|
|
610
|
+
] as const
|
|
611
|
+
for (const [side, ref] of contractEntries) {
|
|
612
|
+
if (ref === undefined) continue
|
|
613
|
+
const result = ContractRefSchema.safeParse(ref)
|
|
614
|
+
if (!result.success) {
|
|
615
|
+
addIssue(
|
|
616
|
+
ctx,
|
|
617
|
+
['resources', resource.id, 'ontology', 'contract', side],
|
|
618
|
+
`Resource "${resource.id}" contract.${side} "${ref}" is not a valid ContractRef (expected "package/subpath#ExportName")`
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
Object.values(model.roles).forEach((role) => {
|
|
626
|
+
if (role.heldBy === undefined) return
|
|
627
|
+
|
|
628
|
+
asRoleHolderArray(role.heldBy).forEach((holder, holderIndex) => {
|
|
629
|
+
if (holder.kind !== 'agent') return
|
|
630
|
+
|
|
631
|
+
const resource = resourcesById.get(holder.agentId)
|
|
632
|
+
if (resource === undefined) {
|
|
633
|
+
addIssue(
|
|
634
|
+
ctx,
|
|
635
|
+
['roles', role.id, 'heldBy', Array.isArray(role.heldBy) ? holderIndex : 'agentId'],
|
|
636
|
+
`Role "${role.id}" references unknown agent holder resource "${holder.agentId}"`
|
|
637
|
+
)
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (resource.kind !== 'agent') {
|
|
642
|
+
addIssue(
|
|
643
|
+
ctx,
|
|
644
|
+
['roles', role.id, 'heldBy', Array.isArray(role.heldBy) ? holderIndex : 'agentId'],
|
|
645
|
+
`Role "${role.id}" agent holder "${holder.agentId}" must reference an agent resource`
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
// Phase 4: model.knowledge is now Record<id, OrgKnowledgeNode>
|
|
652
|
+
Object.entries(model.knowledge).forEach(([nodeId, node]) => {
|
|
653
|
+
node.ownerIds.forEach((roleId, ownerIndex) => {
|
|
654
|
+
if (!rolesById.has(roleId)) {
|
|
655
|
+
addIssue(
|
|
656
|
+
ctx,
|
|
657
|
+
['knowledge', nodeId, 'ownerIds', ownerIndex],
|
|
658
|
+
`Knowledge node "${node.id}" references unknown owner role "${roleId}"`
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
})
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
for (const diagnostic of ontologyCompilation.diagnostics) {
|
|
665
|
+
addIssue(ctx, diagnostic.path, diagnostic.message)
|
|
666
|
+
}
|
|
667
|
+
}
|