@elevasis/core 0.34.1 → 0.35.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 (33) hide show
  1. package/dist/auth/index.d.ts +74 -2
  2. package/dist/auth/index.js +65 -30
  3. package/dist/index.d.ts +60 -2
  4. package/dist/index.js +52 -1
  5. package/dist/knowledge/index.d.ts +12 -0
  6. package/dist/organization-model/index.d.ts +60 -2
  7. package/dist/organization-model/index.js +52 -1
  8. package/dist/test-utils/index.d.ts +12 -0
  9. package/dist/test-utils/index.js +51 -0
  10. package/package.json +1 -1
  11. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +69 -30
  12. package/src/auth/multi-tenancy/index.ts +29 -26
  13. package/src/auth/multi-tenancy/org-id.test.ts +139 -0
  14. package/src/auth/multi-tenancy/org-id.ts +112 -0
  15. package/src/business/acquisition/api-schemas.test.ts +456 -28
  16. package/src/business/acquisition/ontology-validation.ts +715 -23
  17. package/src/execution/engine/tools/platform/storage/__tests__/storage.test.ts +997 -998
  18. package/src/organization-model/__tests__/domains/systems.test.ts +61 -15
  19. package/src/organization-model/__tests__/domains/topology.test.ts +23 -0
  20. package/src/organization-model/__tests__/schema.test.ts +112 -0
  21. package/src/organization-model/domains/systems.ts +44 -0
  22. package/src/organization-model/domains/topology.ts +18 -1
  23. package/src/organization-model/published.ts +19 -1
  24. package/src/organization-model/schema-refinements.ts +23 -0
  25. package/src/organization-model/types.ts +17 -1
  26. package/src/platform/constants/versions.ts +1 -1
  27. package/src/platform/registry/__tests__/validation.test.ts +254 -15
  28. package/src/platform/registry/index.ts +28 -15
  29. package/src/platform/registry/validation.ts +180 -2
  30. package/src/reference/_generated/contracts.md +44 -0
  31. package/src/supabase/__tests__/helpers.test.ts +92 -51
  32. package/src/supabase/helpers.ts +40 -20
  33. package/src/supabase/index.ts +52 -52
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  compileOrganizationOntology,
3
3
  formatOntologyId,
4
+ parseOntologyId,
4
5
  type OntologyActionType,
5
6
  type OntologyCatalogType,
6
7
  type OntologyId,
@@ -8,12 +9,73 @@ import {
8
9
  type ResolvedOntologyIndex
9
10
  } from '../../organization-model/ontology'
10
11
  import type { OrganizationModel } from '../../organization-model/types'
12
+ import type { ResourceEntry } from '../../organization-model/domains/resources'
13
+ import type { OmTopologyRelationship } from '../../organization-model/domains/topology'
11
14
  import {
12
15
  type LeadGenStageCatalogEntry,
13
16
  type StatefulStateDefinition
14
17
  } from '../../organization-model/domains/sales'
18
+ import { getSystem } from '../../organization-model/helpers'
15
19
  import { getLeadGenStageCatalog } from '../../organization-model/migration-helpers'
16
20
 
21
+ export const LEAD_GEN_API_INTERFACE = {
22
+ systemPath: 'sales.lead-gen',
23
+ interfaceKey: 'api',
24
+ readinessProfile: 'sales.lead-gen.api'
25
+ } as const
26
+
27
+ export const CRM_API_INTERFACE = {
28
+ systemPath: 'sales.crm',
29
+ interfaceKey: 'api',
30
+ readinessProfile: 'sales.crm.api'
31
+ } as const
32
+
33
+ export const LEAD_GEN_CRM_HANDOFF_INTERFACE = {
34
+ systemPath: 'sales.lead-gen',
35
+ interfaceKey: 'crm-handoff',
36
+ readinessProfile: 'sales.lead-gen.crm-handoff'
37
+ } as const
38
+
39
+ const LEAD_GEN_API_READINESS_LABEL = LEAD_GEN_API_INTERFACE.readinessProfile
40
+ const CRM_API_READINESS_LABEL = CRM_API_INTERFACE.readinessProfile
41
+ const LEAD_GEN_CRM_HANDOFF_READINESS_LABEL = LEAD_GEN_CRM_HANDOFF_INTERFACE.readinessProfile
42
+
43
+ export const LEAD_GEN_LIST_OBJECT_ONTOLOGY_ID = formatOntologyId({
44
+ scope: 'sales.lead-gen',
45
+ kind: 'object',
46
+ localId: 'list'
47
+ })
48
+
49
+ export const LEAD_GEN_COMPANY_OBJECT_ONTOLOGY_ID = formatOntologyId({
50
+ scope: 'sales.lead-gen',
51
+ kind: 'object',
52
+ localId: 'company'
53
+ })
54
+
55
+ export const LEAD_GEN_CONTACT_OBJECT_ONTOLOGY_ID = formatOntologyId({
56
+ scope: 'sales.lead-gen',
57
+ kind: 'object',
58
+ localId: 'contact'
59
+ })
60
+
61
+ export const LEAD_GEN_BUILD_TEMPLATE_CATALOG_ONTOLOGY_ID = formatOntologyId({
62
+ scope: 'sales.lead-gen',
63
+ kind: 'catalog',
64
+ localId: 'build-template'
65
+ })
66
+
67
+ export const LEAD_GEN_COMPANY_STAGE_CATALOG_ONTOLOGY_ID = formatOntologyId({
68
+ scope: 'sales.lead-gen',
69
+ kind: 'catalog',
70
+ localId: 'company-stage'
71
+ })
72
+
73
+ export const LEAD_GEN_CONTACT_STAGE_CATALOG_ONTOLOGY_ID = formatOntologyId({
74
+ scope: 'sales.lead-gen',
75
+ kind: 'catalog',
76
+ localId: 'contact-stage'
77
+ })
78
+
17
79
  export const CRM_PIPELINE_CATALOG_ONTOLOGY_ID = formatOntologyId({
18
80
  scope: 'sales.crm',
19
81
  kind: 'catalog',
@@ -26,11 +88,74 @@ export const LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID = formatOntologyId({
26
88
  localId: 'lead-gen.stage-catalog'
27
89
  })
28
90
 
29
- export type BusinessOntologyValidationIndex = {
91
+ type BusinessOntologyValidationIndexBase = {
30
92
  ontology: ResolvedOntologyIndex
93
+ actionTypesByLegacyId: Record<string, OntologyActionType>
94
+ }
95
+
96
+ export type LeadGenApiOntologyValidationIndex = BusinessOntologyValidationIndexBase & {
97
+ leadGenStageCatalog: OntologyCatalogType
98
+ }
99
+
100
+ export type CrmApiOntologyValidationIndex = BusinessOntologyValidationIndexBase & {
31
101
  crmPipelineCatalog: OntologyCatalogType
102
+ }
103
+
104
+ export type LeadGenCrmHandoffOntologyValidationIndex = BusinessOntologyValidationIndexBase & {
32
105
  leadGenStageCatalog: OntologyCatalogType
33
- actionTypesByLegacyId: Record<string, OntologyActionType>
106
+ crmPipelineCatalog: OntologyCatalogType
107
+ bridgeGrant?: OmTopologyRelationship
108
+ }
109
+
110
+ export type BusinessOntologyValidationIndex = LeadGenApiOntologyValidationIndex & CrmApiOntologyValidationIndex
111
+
112
+ export type SystemInterfaceReadinessIssueFamily =
113
+ | 'SYSTEM_INTERFACE_MISSING'
114
+ | 'SYSTEM_INTERFACE_DISABLED'
115
+ | 'SYSTEM_INTERFACE_INVALID'
116
+ | 'SYSTEM_INTERFACE_NOT_READY'
117
+ | 'SYSTEM_BRIDGE_NOT_READY'
118
+
119
+ export interface SystemInterfaceReadinessIssue {
120
+ family: SystemInterfaceReadinessIssueFamily
121
+ code: string
122
+ path?: string
123
+ ref?: string
124
+ message: string
125
+ }
126
+
127
+ export interface SystemInterfaceReadinessRequest {
128
+ systemPath: string
129
+ interfaceKey: string
130
+ }
131
+
132
+ export interface SystemInterfaceReadinessResult {
133
+ ready: boolean
134
+ systemPath: string
135
+ interfaceKey: string
136
+ readinessProfile?: string
137
+ scopedResourceIds: string[]
138
+ issues: SystemInterfaceReadinessIssue[]
139
+ }
140
+
141
+ export class SystemInterfaceReadinessError extends Error {
142
+ public readonly code: SystemInterfaceReadinessIssueFamily
143
+ public readonly statusCode = 503
144
+ public readonly systemPath: string
145
+ public readonly interfaceKey: string
146
+ public readonly readinessProfile?: string
147
+ public readonly issues: SystemInterfaceReadinessIssue[]
148
+
149
+ constructor(result: SystemInterfaceReadinessResult) {
150
+ const firstIssue = result.issues[0]
151
+ super(formatInterfaceReadinessFailure(result))
152
+ this.name = 'SystemInterfaceReadinessError'
153
+ this.code = firstIssue?.family ?? 'SYSTEM_INTERFACE_NOT_READY'
154
+ this.systemPath = result.systemPath
155
+ this.interfaceKey = result.interfaceKey
156
+ this.readinessProfile = result.readinessProfile
157
+ this.issues = result.issues
158
+ }
34
159
  }
35
160
 
36
161
  function createLeadGenStageCatalog(model: OrganizationModel): OntologyCatalogType {
@@ -55,17 +180,344 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
55
180
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
56
181
  }
57
182
 
58
- function mergeBridgeCatalogs(model: OrganizationModel): OrganizationModel {
183
+ function addReadinessIssue(
184
+ issues: SystemInterfaceReadinessIssue[],
185
+ family: SystemInterfaceReadinessIssueFamily,
186
+ code: string,
187
+ message: string,
188
+ details: Pick<SystemInterfaceReadinessIssue, 'path' | 'ref'> = {}
189
+ ): void {
190
+ issues.push({
191
+ family,
192
+ code,
193
+ message,
194
+ ...details
195
+ })
196
+ }
197
+
198
+ function formatInterfaceIdentity(systemPath: string, interfaceKey: string): string {
199
+ return `${systemPath}/${interfaceKey}`
200
+ }
201
+
202
+ function profileForInterface(systemPath: string, interfaceKey: string, readinessProfile?: string): string {
203
+ return readinessProfile ?? `${systemPath}.${interfaceKey}`
204
+ }
205
+
206
+ function readinessMarkerPath(context: { systemPath: string; interfaceKey: string }): string {
207
+ return context.interfaceKey === 'api'
208
+ ? `systems.${context.systemPath}.apiInterface`
209
+ : `systems.${context.systemPath}.derivedCrmHandoffReadiness`
210
+ }
211
+
212
+ function formatInterfaceReadinessFailure(result: SystemInterfaceReadinessResult): string {
213
+ const identity = formatInterfaceIdentity(result.systemPath, result.interfaceKey)
214
+ const issueSummary = result.issues.map((issue) => `${issue.family}:${issue.code}: ${issue.message}`).join('; ')
215
+ return `${identity} readiness failed${result.readinessProfile ? ` (${result.readinessProfile})` : ''}: ${issueSummary}`
216
+ }
217
+
218
+ function throwIfInterfaceNotReady(result: SystemInterfaceReadinessResult): void {
219
+ if (result.ready) return
220
+ throw new SystemInterfaceReadinessError(result)
221
+ }
222
+
223
+ function getActiveScopedResources(
224
+ model: OrganizationModel,
225
+ resourceIds: string[],
226
+ issues: SystemInterfaceReadinessIssue[],
227
+ context: { systemPath: string; interfaceKey: string }
228
+ ): ResourceEntry[] {
229
+ const resources: ResourceEntry[] = []
230
+
231
+ for (const [index, resourceId] of resourceIds.entries()) {
232
+ const resource = model.resources?.[resourceId]
233
+ const path = `${readinessMarkerPath(context)}.resourceIds.${index}`
234
+ if (resource === undefined) {
235
+ addReadinessIssue(
236
+ issues,
237
+ 'SYSTEM_INTERFACE_NOT_READY',
238
+ 'missing-resource',
239
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" scopes missing resource "${resourceId}".`,
240
+ { path, ref: resourceId }
241
+ )
242
+ continue
243
+ }
244
+
245
+ if (resource.systemPath !== context.systemPath) {
246
+ addReadinessIssue(
247
+ issues,
248
+ 'SYSTEM_INTERFACE_INVALID',
249
+ 'resource-system-mismatch',
250
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" scopes resource "${resourceId}" from system "${resource.systemPath}".`,
251
+ { path, ref: resourceId }
252
+ )
253
+ continue
254
+ }
255
+
256
+ if (resource.status !== 'active') {
257
+ addReadinessIssue(
258
+ issues,
259
+ 'SYSTEM_INTERFACE_NOT_READY',
260
+ 'inactive-resource',
261
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" scopes inactive resource "${resourceId}".`,
262
+ { path, ref: resourceId }
263
+ )
264
+ continue
265
+ }
266
+
267
+ resources.push(resource)
268
+ }
269
+
270
+ return resources
271
+ }
272
+
273
+ function resourceBindingIds(resources: ResourceEntry[], key: 'reads' | 'writes' | 'usesCatalogs' | 'actions' | 'emits'): Set<string> {
274
+ return new Set(resources.flatMap((resource) => resource.ontology?.[key] ?? []))
275
+ }
276
+
277
+ function requireScopedBinding(
278
+ issues: SystemInterfaceReadinessIssue[],
279
+ boundIds: Set<string>,
280
+ bindingKey: 'reads' | 'writes' | 'usesCatalogs' | 'actions' | 'emits',
281
+ ontologyId: string,
282
+ context: { systemPath: string; interfaceKey: string }
283
+ ): void {
284
+ if (boundIds.has(ontologyId)) return
285
+
286
+ addReadinessIssue(
287
+ issues,
288
+ 'SYSTEM_INTERFACE_NOT_READY',
289
+ 'missing-resource-binding',
290
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" has no active scoped resource with ontology.${bindingKey} binding "${ontologyId}".`,
291
+ { path: `${readinessMarkerPath(context)}.resourceIds`, ref: ontologyId }
292
+ )
293
+ }
294
+
295
+ function ontologyOwnerMatches(ontologyId: string, expectedSystemPath: string, catalog: OntologyCatalogType | undefined): boolean {
296
+ if (catalog?.ownerSystemId !== undefined) return catalog.ownerSystemId === expectedSystemPath
297
+ return parseOntologyId(ontologyId).scope === expectedSystemPath
298
+ }
299
+
300
+ function requireObjectReadiness(
301
+ issues: SystemInterfaceReadinessIssue[],
302
+ index: BusinessOntologyValidationIndexBase,
303
+ objectId: OntologyId,
304
+ context: { systemPath: string; interfaceKey: string }
305
+ ): void {
306
+ const object = index.ontology.objectTypes[objectId]
307
+ if (object === undefined) {
308
+ addReadinessIssue(
309
+ issues,
310
+ 'SYSTEM_INTERFACE_NOT_READY',
311
+ 'missing-object',
312
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" is missing required object type "${objectId}".`,
313
+ { ref: objectId }
314
+ )
315
+ return
316
+ }
317
+
318
+ const ownerSystemId = object.ownerSystemId ?? parseOntologyId(objectId).scope
319
+ if (ownerSystemId !== context.systemPath) {
320
+ addReadinessIssue(
321
+ issues,
322
+ 'SYSTEM_INTERFACE_INVALID',
323
+ 'foreign-object',
324
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" requires object "${objectId}" owned by "${ownerSystemId}".`,
325
+ { ref: objectId }
326
+ )
327
+ }
328
+ }
329
+
330
+ function requireCatalogReadiness(
331
+ issues: SystemInterfaceReadinessIssue[],
332
+ index: BusinessOntologyValidationIndexBase,
333
+ catalogId: OntologyId,
334
+ context: { systemPath: string; interfaceKey: string; allowForeignOwner?: boolean }
335
+ ): OntologyCatalogType | undefined {
336
+ const catalog = index.ontology.catalogTypes[catalogId]
337
+ if (catalog === undefined) {
338
+ addReadinessIssue(
339
+ issues,
340
+ 'SYSTEM_INTERFACE_NOT_READY',
341
+ 'missing-catalog',
342
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" is missing required catalog "${catalogId}".`,
343
+ { ref: catalogId }
344
+ )
345
+ return undefined
346
+ }
347
+
348
+ if (context.allowForeignOwner !== true && !ontologyOwnerMatches(catalogId, context.systemPath, catalog)) {
349
+ addReadinessIssue(
350
+ issues,
351
+ 'SYSTEM_INTERFACE_INVALID',
352
+ 'foreign-catalog',
353
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" requires catalog "${catalogId}" owned by "${catalog.ownerSystemId ?? parseOntologyId(catalogId).scope}".`,
354
+ { ref: catalogId }
355
+ )
356
+ }
357
+
358
+ if (Object.keys(getCatalogEntries(catalog)).length === 0) {
359
+ addReadinessIssue(
360
+ issues,
361
+ 'SYSTEM_INTERFACE_NOT_READY',
362
+ 'empty-catalog',
363
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" requires catalog entries for "${catalogId}".`,
364
+ { ref: catalogId }
365
+ )
366
+ }
367
+
368
+ return catalog
369
+ }
370
+
371
+ function requireLeadGenInterfaceReadiness(
372
+ issues: SystemInterfaceReadinessIssue[],
373
+ index: BusinessOntologyValidationIndexBase,
374
+ resources: ResourceEntry[],
375
+ context: { systemPath: string; interfaceKey: string }
376
+ ): OntologyCatalogType | undefined {
377
+ const reads = resourceBindingIds(resources, 'reads')
378
+ const catalogs = resourceBindingIds(resources, 'usesCatalogs')
379
+
380
+ for (const objectId of [
381
+ LEAD_GEN_LIST_OBJECT_ONTOLOGY_ID,
382
+ LEAD_GEN_COMPANY_OBJECT_ONTOLOGY_ID,
383
+ LEAD_GEN_CONTACT_OBJECT_ONTOLOGY_ID
384
+ ]) {
385
+ requireObjectReadiness(issues, index, objectId, context)
386
+ requireScopedBinding(issues, reads, 'reads', objectId, context)
387
+ }
388
+
389
+ for (const catalogId of [
390
+ LEAD_GEN_BUILD_TEMPLATE_CATALOG_ONTOLOGY_ID,
391
+ LEAD_GEN_COMPANY_STAGE_CATALOG_ONTOLOGY_ID,
392
+ LEAD_GEN_CONTACT_STAGE_CATALOG_ONTOLOGY_ID
393
+ ]) {
394
+ requireCatalogReadiness(issues, index, catalogId, context)
395
+ requireScopedBinding(issues, catalogs, 'usesCatalogs', catalogId, context)
396
+ }
397
+
398
+ const templateStepCatalog = findLeadGenTemplateStepCatalog(index)
399
+ if (templateStepCatalog === undefined) {
400
+ addReadinessIssue(
401
+ issues,
402
+ 'SYSTEM_INTERFACE_NOT_READY',
403
+ 'missing-template-step-catalog',
404
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" is missing a lead-gen template-step catalog.`
405
+ )
406
+ } else {
407
+ requireCatalogReadiness(issues, index, templateStepCatalog.id, context)
408
+ requireScopedBinding(issues, catalogs, 'usesCatalogs', templateStepCatalog.id, context)
409
+ }
410
+
411
+ const leadGenStageCatalog = requireCatalogReadiness(issues, index, LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID, context)
412
+ return leadGenStageCatalog
413
+ }
414
+
415
+ function requireCrmInterfaceReadiness(
416
+ issues: SystemInterfaceReadinessIssue[],
417
+ index: BusinessOntologyValidationIndexBase,
418
+ resources: ResourceEntry[],
419
+ context: { systemPath: string; interfaceKey: string; allowForeignOwner?: boolean }
420
+ ): OntologyCatalogType | undefined {
421
+ const catalogs = resourceBindingIds(resources, 'usesCatalogs')
422
+ const crmPipelineCatalog = requireCatalogReadiness(issues, index, CRM_PIPELINE_CATALOG_ONTOLOGY_ID, context)
423
+ requireScopedBinding(issues, catalogs, 'usesCatalogs', CRM_PIPELINE_CATALOG_ONTOLOGY_ID, context)
424
+ return crmPipelineCatalog
425
+ }
426
+
427
+ function findSystemInterfaceGrant(
428
+ model: OrganizationModel,
429
+ consumer: { systemPath: string; interfaceKey: string },
430
+ provider: { systemPath: string; interfaceKey: string }
431
+ ): OmTopologyRelationship | undefined {
432
+ return Object.values(model.topology?.relationships ?? {}).find((relationship) => {
433
+ const grant = relationship.metadata?.['systemInterfaceGrant']
434
+ if (!isPlainRecord(grant) || !isPlainRecord(grant.consumer) || !isPlainRecord(grant.provider)) return false
435
+ return (
436
+ grant.consumer.systemPath === consumer.systemPath &&
437
+ grant.consumer.interfaceKey === consumer.interfaceKey &&
438
+ grant.provider.systemPath === provider.systemPath &&
439
+ grant.provider.interfaceKey === provider.interfaceKey
440
+ )
441
+ })
442
+ }
443
+
444
+ function requireHandoffBridgeReadiness(
445
+ issues: SystemInterfaceReadinessIssue[],
446
+ model: OrganizationModel,
447
+ context: { systemPath: string; interfaceKey: string }
448
+ ): OmTopologyRelationship | undefined {
449
+ const crmResult = computeInterfaceReadiness(model, CRM_API_INTERFACE)
450
+ if (!crmResult.ready) {
451
+ for (const issue of crmResult.issues) {
452
+ addReadinessIssue(
453
+ issues,
454
+ 'SYSTEM_BRIDGE_NOT_READY',
455
+ issue.code,
456
+ `Provider interface ${formatInterfaceIdentity(CRM_API_INTERFACE.systemPath, CRM_API_INTERFACE.interfaceKey)} is not ready: ${issue.message}`,
457
+ { path: issue.path, ref: issue.ref }
458
+ )
459
+ }
460
+ }
461
+
462
+ const bridgeGrant = findSystemInterfaceGrant(model, context, CRM_API_INTERFACE)
463
+ if (bridgeGrant === undefined) {
464
+ addReadinessIssue(
465
+ issues,
466
+ 'SYSTEM_BRIDGE_NOT_READY',
467
+ 'missing-topology-grant',
468
+ `System Interface "${formatInterfaceIdentity(context.systemPath, context.interfaceKey)}" requires a scoped topology grant to "${formatInterfaceIdentity(CRM_API_INTERFACE.systemPath, CRM_API_INTERFACE.interfaceKey)}".`
469
+ )
470
+ }
471
+
472
+ return bridgeGrant
473
+ }
474
+
475
+ function getLeadGenCrmHandoffResourceIds(model: OrganizationModel): string[] {
476
+ return Object.values(model.resources ?? {})
477
+ .filter(
478
+ (resource) =>
479
+ resource.systemPath === LEAD_GEN_CRM_HANDOFF_INTERFACE.systemPath &&
480
+ resource.ontology?.usesCatalogs?.includes(CRM_PIPELINE_CATALOG_ONTOLOGY_ID) === true
481
+ )
482
+ .map((resource) => resource.id)
483
+ }
484
+
485
+ function getSystemInterfaceReadinessMarker(
486
+ model: OrganizationModel,
487
+ request: SystemInterfaceReadinessRequest
488
+ ): { lifecycle: string; readinessProfile?: string; resourceIds?: string[] } | undefined {
489
+ const system = getSystem(model, request.systemPath)
490
+ if (system === undefined) return undefined
491
+
492
+ if (request.interfaceKey === LEAD_GEN_API_INTERFACE.interfaceKey) {
493
+ return system.apiInterface
494
+ }
495
+
496
+ if (
497
+ request.systemPath === LEAD_GEN_CRM_HANDOFF_INTERFACE.systemPath &&
498
+ request.interfaceKey === LEAD_GEN_CRM_HANDOFF_INTERFACE.interfaceKey
499
+ ) {
500
+ return {
501
+ lifecycle: 'active',
502
+ readinessProfile: LEAD_GEN_CRM_HANDOFF_INTERFACE.readinessProfile,
503
+ resourceIds: getLeadGenCrmHandoffResourceIds(model)
504
+ }
505
+ }
506
+
507
+ return undefined
508
+ }
509
+
510
+ function mergeLeadGenDerivedCatalogs(model: OrganizationModel): OrganizationModel {
59
511
  const baseCatalogTypes = model.ontology?.catalogTypes ?? {}
60
- const bridgeCatalogTypes: Record<OntologyId, OntologyCatalogType> = {}
512
+ const derivedCatalogTypes: Record<OntologyId, OntologyCatalogType> = {}
61
513
 
62
- // CRM pipeline catalog is now authored directly in @repo/elevasis-core canonicalOrganizationModel
63
- // at 'sales.crm:catalog/crm.pipeline'. No bridge injection needed the model self-supplies it.
514
+ // Lead-gen validators consume a normalized stage catalog derived from the
515
+ // owning system's company/contact stage catalogs. CRM catalogs are never injected here.
64
516
  if (baseCatalogTypes[LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID] === undefined) {
65
- bridgeCatalogTypes[LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID] = createLeadGenStageCatalog(model)
517
+ derivedCatalogTypes[LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID] = createLeadGenStageCatalog(model)
66
518
  }
67
519
 
68
- if (Object.keys(bridgeCatalogTypes).length === 0) return model
520
+ if (Object.keys(derivedCatalogTypes).length === 0) return model
69
521
 
70
522
  return {
71
523
  ...model,
@@ -73,33 +525,273 @@ function mergeBridgeCatalogs(model: OrganizationModel): OrganizationModel {
73
525
  ...(model.ontology ?? {}),
74
526
  catalogTypes: {
75
527
  ...baseCatalogTypes,
76
- ...bridgeCatalogTypes
528
+ ...derivedCatalogTypes
77
529
  }
78
530
  } satisfies OntologyScope
79
531
  }
80
532
  }
81
533
 
82
- export function compileBusinessOntologyValidationIndex(model: OrganizationModel): BusinessOntologyValidationIndex {
83
- const compilation = compileOrganizationOntology(mergeBridgeCatalogs(model))
534
+ function compileBusinessOntology(model: OrganizationModel, contractId: string): BusinessOntologyValidationIndexBase {
535
+ const compilation = compileOrganizationOntology(mergeLeadGenDerivedCatalogs(model))
84
536
  if (compilation.diagnostics.length > 0) {
85
537
  const summary = compilation.diagnostics.map((diagnostic) => diagnostic.message).join('; ')
86
- throw new Error(`Business ontology validation index failed to compile: ${summary}`)
87
- }
88
-
89
- const crmPipelineCatalog = compilation.ontology.catalogTypes[CRM_PIPELINE_CATALOG_ONTOLOGY_ID]
90
- const leadGenStageCatalog = compilation.ontology.catalogTypes[LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID]
91
- if (crmPipelineCatalog === undefined || leadGenStageCatalog === undefined) {
92
- throw new Error('Business ontology validation index is missing CRM or lead-gen catalog bridge records')
538
+ throw new Error(`${contractId} ontology validation index failed to compile: ${summary}`)
93
539
  }
94
540
 
95
541
  return {
96
542
  ontology: compilation.ontology,
97
- crmPipelineCatalog,
98
- leadGenStageCatalog,
99
543
  actionTypesByLegacyId: indexActionTypesByLegacyId(compilation.ontology.actionTypes)
100
544
  }
101
545
  }
102
546
 
547
+ function tryCompileBusinessOntology(
548
+ model: OrganizationModel,
549
+ readinessProfile: string,
550
+ issues: SystemInterfaceReadinessIssue[]
551
+ ): BusinessOntologyValidationIndexBase | undefined {
552
+ try {
553
+ return compileBusinessOntology(model, readinessProfile)
554
+ } catch (error) {
555
+ addReadinessIssue(
556
+ issues,
557
+ 'SYSTEM_INTERFACE_INVALID',
558
+ 'ontology-compile-failed',
559
+ error instanceof Error ? error.message : String(error)
560
+ )
561
+ return undefined
562
+ }
563
+ }
564
+
565
+ export function computeInterfaceReadiness(
566
+ model: OrganizationModel,
567
+ request: SystemInterfaceReadinessRequest
568
+ ): SystemInterfaceReadinessResult {
569
+ const issues: SystemInterfaceReadinessIssue[] = []
570
+ const system = getSystem(model, request.systemPath)
571
+ const systemInterface = getSystemInterfaceReadinessMarker(model, request)
572
+ const readinessProfile =
573
+ systemInterface === undefined
574
+ ? undefined
575
+ : profileForInterface(request.systemPath, request.interfaceKey, systemInterface.readinessProfile)
576
+ const scopedResourceIds = systemInterface?.resourceIds ?? []
577
+
578
+ if (system === undefined) {
579
+ addReadinessIssue(
580
+ issues,
581
+ 'SYSTEM_INTERFACE_MISSING',
582
+ 'missing-system',
583
+ `System "${request.systemPath}" is missing.`,
584
+ { ref: request.systemPath }
585
+ )
586
+ return { ready: false, systemPath: request.systemPath, interfaceKey: request.interfaceKey, scopedResourceIds, issues }
587
+ }
588
+
589
+ if (systemInterface === undefined) {
590
+ addReadinessIssue(
591
+ issues,
592
+ 'SYSTEM_INTERFACE_MISSING',
593
+ 'missing-interface',
594
+ `System "${request.systemPath}" does not declare interface "${request.interfaceKey}".`,
595
+ { path: readinessMarkerPath(request) }
596
+ )
597
+ return { ready: false, systemPath: request.systemPath, interfaceKey: request.interfaceKey, scopedResourceIds, issues }
598
+ }
599
+
600
+ if (systemInterface.lifecycle !== 'active') {
601
+ addReadinessIssue(
602
+ issues,
603
+ 'SYSTEM_INTERFACE_DISABLED',
604
+ 'inactive-interface',
605
+ `System Interface "${formatInterfaceIdentity(request.systemPath, request.interfaceKey)}" lifecycle is "${systemInterface.lifecycle}".`,
606
+ { path: `${readinessMarkerPath(request)}.lifecycle` }
607
+ )
608
+ }
609
+
610
+ const supportedProfiles = [
611
+ LEAD_GEN_API_INTERFACE.readinessProfile,
612
+ CRM_API_INTERFACE.readinessProfile,
613
+ LEAD_GEN_CRM_HANDOFF_INTERFACE.readinessProfile
614
+ ] as const
615
+ const supportedProfile =
616
+ readinessProfile !== undefined && supportedProfiles.some((profile) => profile === readinessProfile)
617
+
618
+ if (!supportedProfile) {
619
+ addReadinessIssue(
620
+ issues,
621
+ 'SYSTEM_INTERFACE_INVALID',
622
+ 'unknown-readiness-profile',
623
+ `System Interface "${formatInterfaceIdentity(request.systemPath, request.interfaceKey)}" references unknown readiness profile "${readinessProfile}".`,
624
+ { path: `${readinessMarkerPath(request)}.readinessProfile`, ref: readinessProfile }
625
+ )
626
+ return {
627
+ ready: false,
628
+ systemPath: request.systemPath,
629
+ interfaceKey: request.interfaceKey,
630
+ readinessProfile,
631
+ scopedResourceIds,
632
+ issues
633
+ }
634
+ }
635
+
636
+ if (scopedResourceIds.length === 0) {
637
+ addReadinessIssue(
638
+ issues,
639
+ 'SYSTEM_INTERFACE_NOT_READY',
640
+ 'missing-scoped-resources',
641
+ `System Interface "${formatInterfaceIdentity(request.systemPath, request.interfaceKey)}" must scope at least one active resource.`,
642
+ { path: `${readinessMarkerPath(request)}.resourceIds` }
643
+ )
644
+ }
645
+
646
+ const checkedReadinessProfile = readinessProfile
647
+ if (checkedReadinessProfile === undefined) {
648
+ throw new Error('Supported readiness profile unexpectedly resolved to undefined')
649
+ }
650
+
651
+ const resources = getActiveScopedResources(model, scopedResourceIds, issues, request)
652
+ const index = tryCompileBusinessOntology(model, checkedReadinessProfile, issues)
653
+
654
+ if (index !== undefined) {
655
+ if (checkedReadinessProfile === LEAD_GEN_API_INTERFACE.readinessProfile) {
656
+ requireLeadGenInterfaceReadiness(issues, index, resources, request)
657
+ } else if (checkedReadinessProfile === CRM_API_INTERFACE.readinessProfile) {
658
+ requireCrmInterfaceReadiness(issues, index, resources, request)
659
+ } else if (checkedReadinessProfile === LEAD_GEN_CRM_HANDOFF_INTERFACE.readinessProfile) {
660
+ requireLeadGenInterfaceReadiness(issues, index, resources, request)
661
+ requireCrmInterfaceReadiness(issues, index, resources, { ...request, allowForeignOwner: true })
662
+ requireHandoffBridgeReadiness(issues, model, request)
663
+ }
664
+ }
665
+
666
+ return {
667
+ ready: issues.length === 0,
668
+ systemPath: request.systemPath,
669
+ interfaceKey: request.interfaceKey,
670
+ readinessProfile,
671
+ scopedResourceIds,
672
+ issues
673
+ }
674
+ }
675
+
676
+ function requireObjectType(index: BusinessOntologyValidationIndexBase, objectId: OntologyId, contractId: string): void {
677
+ if (index.ontology.objectTypes[objectId] === undefined) {
678
+ throw new Error(`${contractId} is missing required object type: ${objectId}`)
679
+ }
680
+ }
681
+
682
+ function requireCatalog(
683
+ index: BusinessOntologyValidationIndexBase,
684
+ catalogId: OntologyId,
685
+ contractId: string
686
+ ): OntologyCatalogType {
687
+ const catalog = index.ontology.catalogTypes[catalogId]
688
+ if (catalog === undefined) {
689
+ throw new Error(`${contractId} is missing required catalog: ${catalogId}`)
690
+ }
691
+ return catalog
692
+ }
693
+
694
+ function requireCatalogEntries(catalog: OntologyCatalogType, contractId: string): void {
695
+ if (Object.keys(getCatalogEntries(catalog)).length === 0) {
696
+ throw new Error(`${contractId} requires catalog entries for ${catalog.id}`)
697
+ }
698
+ }
699
+
700
+ function findLeadGenTemplateStepCatalog(index: BusinessOntologyValidationIndexBase): OntologyCatalogType | undefined {
701
+ return Object.values(index.ontology.catalogTypes).find(
702
+ (catalog) =>
703
+ catalog.ownerSystemId === 'sales.lead-gen' &&
704
+ catalog.kind === 'template-step' &&
705
+ catalog.appliesTo === LEAD_GEN_LIST_OBJECT_ONTOLOGY_ID
706
+ )
707
+ }
708
+
709
+ function requireLeadGenApiReadiness(index: BusinessOntologyValidationIndexBase): OntologyCatalogType {
710
+ requireObjectType(index, LEAD_GEN_LIST_OBJECT_ONTOLOGY_ID, LEAD_GEN_API_READINESS_LABEL)
711
+ requireObjectType(index, LEAD_GEN_COMPANY_OBJECT_ONTOLOGY_ID, LEAD_GEN_API_READINESS_LABEL)
712
+ requireObjectType(index, LEAD_GEN_CONTACT_OBJECT_ONTOLOGY_ID, LEAD_GEN_API_READINESS_LABEL)
713
+
714
+ requireCatalogEntries(
715
+ requireCatalog(index, LEAD_GEN_BUILD_TEMPLATE_CATALOG_ONTOLOGY_ID, LEAD_GEN_API_READINESS_LABEL),
716
+ LEAD_GEN_API_READINESS_LABEL
717
+ )
718
+ requireCatalogEntries(
719
+ requireCatalog(index, LEAD_GEN_COMPANY_STAGE_CATALOG_ONTOLOGY_ID, LEAD_GEN_API_READINESS_LABEL),
720
+ LEAD_GEN_API_READINESS_LABEL
721
+ )
722
+ requireCatalogEntries(
723
+ requireCatalog(index, LEAD_GEN_CONTACT_STAGE_CATALOG_ONTOLOGY_ID, LEAD_GEN_API_READINESS_LABEL),
724
+ LEAD_GEN_API_READINESS_LABEL
725
+ )
726
+
727
+ const templateStepCatalog = findLeadGenTemplateStepCatalog(index)
728
+ if (templateStepCatalog === undefined) {
729
+ throw new Error(`${LEAD_GEN_API_READINESS_LABEL} is missing a lead-gen template-step catalog`)
730
+ }
731
+ requireCatalogEntries(templateStepCatalog, LEAD_GEN_API_READINESS_LABEL)
732
+
733
+ const leadGenStageCatalog = requireCatalog(index, LEAD_GEN_STAGE_CATALOG_ONTOLOGY_ID, LEAD_GEN_API_READINESS_LABEL)
734
+ requireCatalogEntries(leadGenStageCatalog, LEAD_GEN_API_READINESS_LABEL)
735
+ return leadGenStageCatalog
736
+ }
737
+
738
+ function requireCrmApiReadiness(index: BusinessOntologyValidationIndexBase): OntologyCatalogType {
739
+ const crmPipelineCatalog = requireCatalog(index, CRM_PIPELINE_CATALOG_ONTOLOGY_ID, CRM_API_READINESS_LABEL)
740
+ requireCatalogEntries(crmPipelineCatalog, CRM_API_READINESS_LABEL)
741
+ return crmPipelineCatalog
742
+ }
743
+
744
+ export function compileLeadGenApiOntologyValidationIndex(model: OrganizationModel): LeadGenApiOntologyValidationIndex {
745
+ throwIfInterfaceNotReady(computeInterfaceReadiness(model, LEAD_GEN_API_INTERFACE))
746
+ const index = compileBusinessOntology(model, LEAD_GEN_API_READINESS_LABEL)
747
+ return {
748
+ ...index,
749
+ leadGenStageCatalog: requireLeadGenApiReadiness(index)
750
+ }
751
+ }
752
+
753
+ export function compileCrmApiOntologyValidationIndex(model: OrganizationModel): CrmApiOntologyValidationIndex {
754
+ throwIfInterfaceNotReady(computeInterfaceReadiness(model, CRM_API_INTERFACE))
755
+ const index = compileBusinessOntology(model, CRM_API_READINESS_LABEL)
756
+ return {
757
+ ...index,
758
+ crmPipelineCatalog: requireCrmApiReadiness(index)
759
+ }
760
+ }
761
+
762
+ export function compileLeadGenCrmHandoffOntologyValidationIndex(
763
+ model: OrganizationModel
764
+ ): LeadGenCrmHandoffOntologyValidationIndex {
765
+ const readiness = computeInterfaceReadiness(model, LEAD_GEN_CRM_HANDOFF_INTERFACE)
766
+ throwIfInterfaceNotReady(readiness)
767
+ const index = compileBusinessOntology(model, LEAD_GEN_CRM_HANDOFF_READINESS_LABEL)
768
+ return {
769
+ ...index,
770
+ leadGenStageCatalog: requireLeadGenApiReadiness(index),
771
+ crmPipelineCatalog: requireCrmApiReadiness(index),
772
+ bridgeGrant: findSystemInterfaceGrant(model, LEAD_GEN_CRM_HANDOFF_INTERFACE, CRM_API_INTERFACE)
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Legacy mixed acquisition index.
778
+ *
779
+ * Kept for existing API consumers until Wave 3 wires explicit route/service
780
+ * contract requirements. New base lead-gen paths should call
781
+ * `compileLeadGenApiOntologyValidationIndex()`; CRM-aware paths should call the
782
+ * CRM or bridge helpers above.
783
+ */
784
+ export function compileBusinessOntologyValidationIndex(model: OrganizationModel): BusinessOntologyValidationIndex {
785
+ throwIfInterfaceNotReady(computeInterfaceReadiness(model, LEAD_GEN_API_INTERFACE))
786
+ throwIfInterfaceNotReady(computeInterfaceReadiness(model, CRM_API_INTERFACE))
787
+ const index = compileBusinessOntology(model, 'legacy acquisition business ontology')
788
+ return {
789
+ ...index,
790
+ leadGenStageCatalog: requireLeadGenApiReadiness(index),
791
+ crmPipelineCatalog: requireCrmApiReadiness(index)
792
+ }
793
+ }
794
+
103
795
  function indexActionTypesByLegacyId(
104
796
  actionTypes: Record<OntologyId, OntologyActionType>
105
797
  ): Record<string, OntologyActionType> {
@@ -158,7 +850,7 @@ function asLeadGenStageEntry(key: string, value: unknown): LeadGenStageCatalogEn
158
850
  * core does not materialize an Elevasis CRM pipeline singleton.
159
851
  */
160
852
  export function getCrmStatesForStage(
161
- index: BusinessOntologyValidationIndex,
853
+ index: CrmApiOntologyValidationIndex,
162
854
  stageKey: string
163
855
  ): StatefulStateDefinition[] {
164
856
  const stage = getCatalogEntries(index.crmPipelineCatalog)[stageKey]
@@ -175,7 +867,7 @@ export function getCrmStatesForStage(
175
867
  * The lead-gen stage catalog is tenant-model-dependent (it derives from the
176
868
  * organization model's `sales.lead-gen` stage records). Hosts that own a
177
869
  * populated tenant model (e.g. `apps/api` with the Elevasis canonical model)
178
- * MUST build their own index via `compileBusinessOntologyValidationIndex(model)`
870
+ * MUST build their own index via `compileLeadGenApiOntologyValidationIndex(model)`
179
871
  * and call `createLeadGenStageValidators(index)`. The core package stays generic;
180
872
  * it must never import a tenant model.
181
873
  */
@@ -188,7 +880,7 @@ export type LeadGenStageValidators = {
188
880
  resolveLeadGenRecordStageKey(stageKey: string, entity: 'company' | 'contact'): string
189
881
  }
190
882
 
191
- export function createLeadGenStageValidators(index: BusinessOntologyValidationIndex): LeadGenStageValidators {
883
+ export function createLeadGenStageValidators(index: LeadGenApiOntologyValidationIndex): LeadGenStageValidators {
192
884
  const getCatalog = (): Record<string, LeadGenStageCatalogEntry> =>
193
885
  Object.fromEntries(
194
886
  Object.entries(getCatalogEntries(index.leadGenStageCatalog)).map(([key, value]) => [