@elevasis/core 0.23.0 → 0.24.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 (241) hide show
  1. package/dist/index.d.ts +1326 -552
  2. package/dist/index.js +869 -154
  3. package/dist/knowledge/index.d.ts +487 -209
  4. package/dist/knowledge/index.js +104 -1
  5. package/dist/organization-model/index.d.ts +1326 -552
  6. package/dist/organization-model/index.js +869 -154
  7. package/dist/test-utils/index.d.ts +357 -72
  8. package/dist/test-utils/index.js +795 -142
  9. package/package.json +5 -5
  10. package/src/README.md +14 -14
  11. package/src/__tests__/publish.test.ts +24 -24
  12. package/src/__tests__/template-core-compatibility.test.ts +9 -12
  13. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +2102 -2096
  14. package/src/_gen/__tests__/scaffold-contracts.test.ts +30 -30
  15. package/src/auth/multi-tenancy/credentials/__tests__/encryption.test.ts +217 -217
  16. package/src/auth/multi-tenancy/credentials/server/encryption.ts +69 -69
  17. package/src/auth/multi-tenancy/credentials/server/kek-loader.ts +37 -37
  18. package/src/auth/multi-tenancy/index.ts +26 -26
  19. package/src/auth/multi-tenancy/invitations/api-schemas.ts +104 -104
  20. package/src/auth/multi-tenancy/memberships/api-schemas.ts +143 -143
  21. package/src/auth/multi-tenancy/memberships/index.ts +26 -26
  22. package/src/auth/multi-tenancy/memberships/membership.ts +130 -130
  23. package/src/auth/multi-tenancy/organizations/__tests__/api-schemas.test.ts +194 -194
  24. package/src/auth/multi-tenancy/organizations/api-schemas.ts +136 -136
  25. package/src/auth/multi-tenancy/permissions.test.ts +42 -42
  26. package/src/auth/multi-tenancy/permissions.ts +123 -123
  27. package/src/auth/multi-tenancy/role-management/api-schemas.ts +78 -78
  28. package/src/auth/multi-tenancy/role-management/index.ts +16 -16
  29. package/src/auth/multi-tenancy/theme-presets.ts +45 -45
  30. package/src/auth/multi-tenancy/types.ts +57 -57
  31. package/src/auth/multi-tenancy/users/api-schemas.ts +165 -165
  32. package/src/business/README.md +2 -2
  33. package/src/business/acquisition/activity-events.test.ts +250 -250
  34. package/src/business/acquisition/activity-events.ts +93 -93
  35. package/src/business/acquisition/api-schemas.test.ts +1883 -1843
  36. package/src/business/acquisition/api-schemas.ts +1492 -1497
  37. package/src/business/acquisition/build-templates.test.ts +240 -240
  38. package/src/business/acquisition/build-templates.ts +98 -98
  39. package/src/business/acquisition/crm-next-action.test.ts +262 -262
  40. package/src/business/acquisition/crm-next-action.ts +220 -220
  41. package/src/business/acquisition/crm-priority.test.ts +216 -216
  42. package/src/business/acquisition/crm-priority.ts +349 -349
  43. package/src/business/acquisition/crm-state-actions.test.ts +153 -153
  44. package/src/business/acquisition/deal-ownership.test.ts +351 -351
  45. package/src/business/acquisition/deal-ownership.ts +120 -120
  46. package/src/business/acquisition/derive-actions.test.ts +129 -104
  47. package/src/business/acquisition/derive-actions.ts +74 -84
  48. package/src/business/acquisition/index.ts +171 -170
  49. package/src/business/acquisition/ontology-validation.ts +309 -0
  50. package/src/business/acquisition/stateful.ts +30 -30
  51. package/src/business/acquisition/types.ts +396 -396
  52. package/src/business/clients/api-schemas.test.ts +115 -115
  53. package/src/business/clients/api-schemas.ts +158 -158
  54. package/src/business/clients/index.ts +1 -1
  55. package/src/business/crm/api-schemas.ts +40 -40
  56. package/src/business/crm/index.ts +1 -1
  57. package/src/business/deals/api-schemas.ts +87 -87
  58. package/src/business/deals/index.ts +1 -1
  59. package/src/business/index.ts +5 -5
  60. package/src/business/projects/types.ts +144 -144
  61. package/src/commands/queue/types/task.ts +15 -15
  62. package/src/execution/core/runner-types.ts +61 -61
  63. package/src/execution/core/sse-executions.ts +7 -7
  64. package/src/execution/engine/__tests__/fixtures/test-agents.ts +10 -10
  65. package/src/execution/engine/agent/core/__tests__/agent.test.ts +16 -16
  66. package/src/execution/engine/agent/core/__tests__/error-passthrough.test.ts +4 -4
  67. package/src/execution/engine/agent/core/types.ts +25 -25
  68. package/src/execution/engine/agent/index.ts +6 -6
  69. package/src/execution/engine/agent/reasoning/__tests__/request-builder.test.ts +24 -24
  70. package/src/execution/engine/index.ts +443 -443
  71. package/src/execution/engine/tools/integration/server/adapters/apify/__tests__/apify-run-actor.integration.test.ts +298 -298
  72. package/src/execution/engine/tools/integration/server/adapters/apify/apify-adapter.test.ts +55 -55
  73. package/src/execution/engine/tools/integration/server/adapters/apify/apify-adapter.ts +107 -107
  74. package/src/execution/engine/tools/integration/server/adapters/apollo/apollo-adapter.test.ts +48 -48
  75. package/src/execution/engine/tools/integration/server/adapters/apollo/apollo-adapter.ts +99 -99
  76. package/src/execution/engine/tools/integration/server/adapters/apollo/index.ts +1 -1
  77. package/src/execution/engine/tools/integration/server/adapters/attio/__tests__/attio-crud.integration.test.ts +363 -363
  78. package/src/execution/engine/tools/integration/server/adapters/attio/fetch/get-record/index.test.ts +162 -162
  79. package/src/execution/engine/tools/integration/server/adapters/attio/fetch/list-records/index.test.ts +316 -316
  80. package/src/execution/engine/tools/integration/server/adapters/clickup/clickup-adapter.test.ts +18 -18
  81. package/src/execution/engine/tools/integration/server/adapters/clickup/clickup-adapter.ts +194 -194
  82. package/src/execution/engine/tools/integration/server/adapters/clickup/index.ts +7 -7
  83. package/src/execution/engine/tools/integration/server/adapters/gmail/gmail-adapter.ts +204 -204
  84. package/src/execution/engine/tools/integration/server/adapters/gmail/gmail-tools.ts +105 -105
  85. package/src/execution/engine/tools/integration/server/adapters/google-calendar/google-calendar-adapter.ts +428 -428
  86. package/src/execution/engine/tools/integration/server/adapters/google-calendar/index.ts +2 -2
  87. package/src/execution/engine/tools/integration/server/adapters/google-sheets/__tests__/google-sheets.integration.test.ts +261 -261
  88. package/src/execution/engine/tools/integration/server/adapters/instantly/instantly-tools.ts +1474 -1474
  89. package/src/execution/engine/tools/integration/server/adapters/millionverifier/millionverifier-tools.ts +103 -103
  90. package/src/execution/engine/tools/integration/server/adapters/resend/fetch/send-email/index.test.ts +88 -88
  91. package/src/execution/engine/tools/integration/server/adapters/resend/fetch/send-email/index.ts +141 -141
  92. package/src/execution/engine/tools/integration/server/adapters/resend/fetch/utils/types.ts +76 -76
  93. package/src/execution/engine/tools/integration/server/adapters/signature-api/signature-api-tools.ts +182 -182
  94. package/src/execution/engine/tools/integration/server/adapters/stripe/stripe-tools.ts +310 -310
  95. package/src/execution/engine/tools/integration/service.test.ts +239 -239
  96. package/src/execution/engine/tools/integration/service.ts +172 -172
  97. package/src/execution/engine/tools/integration/tool.ts +255 -255
  98. package/src/execution/engine/tools/lead-service-types.ts +1005 -1005
  99. package/src/execution/engine/tools/messages.ts +43 -43
  100. package/src/execution/engine/tools/platform/acquisition/company-tools.ts +7 -7
  101. package/src/execution/engine/tools/platform/acquisition/contact-tools.ts +6 -6
  102. package/src/execution/engine/tools/platform/acquisition/list-tools.ts +6 -6
  103. package/src/execution/engine/tools/platform/acquisition/types.ts +280 -280
  104. package/src/execution/engine/tools/platform/email/types.ts +97 -97
  105. package/src/execution/engine/tools/registry.ts +704 -704
  106. package/src/execution/engine/tools/tool-maps.ts +831 -831
  107. package/src/execution/engine/tools/types.ts +234 -234
  108. package/src/execution/engine/workflow/types.ts +202 -202
  109. package/src/execution/external/__tests__/api-schemas.test.ts +127 -127
  110. package/src/execution/external/api-schemas.ts +40 -40
  111. package/src/execution/external/index.ts +1 -1
  112. package/src/index.ts +18 -18
  113. package/src/integrations/credentials/__tests__/api-schemas.test.ts +420 -420
  114. package/src/integrations/credentials/api-schemas.ts +146 -146
  115. package/src/integrations/credentials/schemas.ts +200 -200
  116. package/src/integrations/oauth/__tests__/provider-registry.test.ts +7 -7
  117. package/src/integrations/oauth/provider-registry.ts +74 -74
  118. package/src/integrations/oauth/server/credentials.ts +43 -43
  119. package/src/integrations/webhook-endpoints/__tests__/api-schemas.test.ts +327 -327
  120. package/src/integrations/webhook-endpoints/api-schemas.ts +103 -103
  121. package/src/integrations/webhook-endpoints/types.ts +58 -58
  122. package/src/knowledge/README.md +32 -32
  123. package/src/knowledge/__tests__/queries.test.ts +626 -535
  124. package/src/knowledge/format.ts +99 -99
  125. package/src/knowledge/index.ts +5 -5
  126. package/src/knowledge/published.ts +5 -5
  127. package/src/knowledge/queries.ts +269 -218
  128. package/src/operations/activities/api-schemas.ts +80 -80
  129. package/src/operations/activities/types.ts +64 -64
  130. package/src/organization-model/README.md +149 -149
  131. package/src/organization-model/__tests__/content-kinds-registry.test.ts +210 -210
  132. package/src/organization-model/__tests__/defaults.test.ts +168 -168
  133. package/src/organization-model/__tests__/domains/actions.test.ts +78 -56
  134. package/src/organization-model/__tests__/domains/customers.test.ts +299 -299
  135. package/src/organization-model/__tests__/domains/entities.test.ts +56 -56
  136. package/src/organization-model/__tests__/domains/goals.test.ts +493 -493
  137. package/src/organization-model/__tests__/domains/identity.test.ts +280 -280
  138. package/src/organization-model/__tests__/domains/navigation.test.ts +268 -268
  139. package/src/organization-model/__tests__/domains/offerings.test.ts +414 -414
  140. package/src/organization-model/__tests__/domains/policies.test.ts +323 -323
  141. package/src/organization-model/__tests__/domains/resource-mappings.test.ts +293 -293
  142. package/src/organization-model/__tests__/domains/resources.test.ts +382 -283
  143. package/src/organization-model/__tests__/domains/roles.test.ts +463 -463
  144. package/src/organization-model/__tests__/domains/statuses.test.ts +246 -246
  145. package/src/organization-model/__tests__/domains/systems.test.ts +209 -209
  146. package/src/organization-model/__tests__/flatten-additive-merge.test.ts +362 -361
  147. package/src/organization-model/__tests__/foundation.test.ts +77 -77
  148. package/src/organization-model/__tests__/get-resources-for-system.test.ts +144 -144
  149. package/src/organization-model/__tests__/graph.test.ts +1246 -887
  150. package/src/organization-model/__tests__/icons.test.ts +10 -1
  151. package/src/organization-model/__tests__/knowledge.test.ts +251 -15
  152. package/src/organization-model/__tests__/lookup-helpers.test.ts +438 -438
  153. package/src/organization-model/__tests__/migration-helpers.test.ts +591 -591
  154. package/src/organization-model/__tests__/prospecting-ssot.test.ts +103 -103
  155. package/src/organization-model/__tests__/recursive-system-schema.test.ts +535 -506
  156. package/src/organization-model/__tests__/resolve.test.ts +274 -164
  157. package/src/organization-model/__tests__/schema.test.ts +834 -301
  158. package/src/organization-model/__tests__/surface-projection.test.ts +284 -284
  159. package/src/organization-model/catalogs/lead-gen.ts +144 -144
  160. package/src/organization-model/content-kinds/config.ts +36 -36
  161. package/src/organization-model/content-kinds/index.ts +76 -72
  162. package/src/organization-model/content-kinds/pipeline.ts +68 -68
  163. package/src/organization-model/content-kinds/registry.ts +44 -44
  164. package/src/organization-model/content-kinds/status.ts +71 -71
  165. package/src/organization-model/content-kinds/template.ts +83 -83
  166. package/src/organization-model/content-kinds/types.ts +117 -117
  167. package/src/organization-model/contracts.ts +27 -27
  168. package/src/organization-model/defaults.ts +40 -50
  169. package/src/organization-model/domains/actions.ts +333 -239
  170. package/src/organization-model/domains/customers.ts +78 -78
  171. package/src/organization-model/domains/entities.ts +144 -144
  172. package/src/organization-model/domains/goals.ts +83 -83
  173. package/src/organization-model/domains/knowledge.ts +117 -101
  174. package/src/organization-model/domains/navigation.ts +139 -139
  175. package/src/organization-model/domains/offerings.ts +71 -71
  176. package/src/organization-model/domains/policies.ts +102 -102
  177. package/src/organization-model/domains/projects.ts +14 -14
  178. package/src/organization-model/domains/prospecting.ts +395 -395
  179. package/src/organization-model/domains/resources.ts +167 -132
  180. package/src/organization-model/domains/roles.ts +96 -96
  181. package/src/organization-model/domains/sales.test.ts +218 -218
  182. package/src/organization-model/domains/sales.ts +380 -380
  183. package/src/organization-model/domains/shared.ts +63 -63
  184. package/src/organization-model/domains/statuses.ts +339 -339
  185. package/src/organization-model/domains/systems.ts +217 -172
  186. package/src/organization-model/foundation.ts +75 -75
  187. package/src/organization-model/graph/build.ts +1016 -888
  188. package/src/organization-model/graph/index.ts +4 -4
  189. package/src/organization-model/graph/link.ts +10 -10
  190. package/src/organization-model/graph/schema.ts +76 -70
  191. package/src/organization-model/graph/types.ts +73 -67
  192. package/src/organization-model/helpers.ts +289 -241
  193. package/src/organization-model/icons.ts +78 -66
  194. package/src/organization-model/index.ts +130 -128
  195. package/src/organization-model/migration-helpers.ts +247 -244
  196. package/src/organization-model/ontology.ts +661 -0
  197. package/src/organization-model/organization-graph.mdx +110 -90
  198. package/src/organization-model/organization-model.mdx +226 -219
  199. package/src/organization-model/published.ts +289 -235
  200. package/src/organization-model/resolve.ts +146 -91
  201. package/src/organization-model/schema.ts +790 -671
  202. package/src/organization-model/surface-projection.ts +212 -212
  203. package/src/organization-model/types.ts +177 -167
  204. package/src/platform/api/types.ts +38 -38
  205. package/src/platform/constants/versions.ts +3 -3
  206. package/src/platform/index.ts +23 -23
  207. package/src/platform/registry/__tests__/command-view.test.ts +10 -10
  208. package/src/platform/registry/__tests__/resource-link.test.ts +35 -35
  209. package/src/platform/registry/__tests__/resource-registry.integration.test.ts +20 -20
  210. package/src/platform/registry/__tests__/resource-registry.nested-systems.test.ts +245 -245
  211. package/src/platform/registry/__tests__/resource-registry.test.ts +2053 -2053
  212. package/src/platform/registry/__tests__/validation.test.ts +1347 -1347
  213. package/src/platform/registry/command-view.ts +10 -10
  214. package/src/platform/registry/index.ts +103 -103
  215. package/src/platform/registry/resource-link.ts +32 -32
  216. package/src/platform/registry/resource-registry.ts +890 -890
  217. package/src/platform/registry/serialization.ts +295 -295
  218. package/src/platform/registry/serialized-types.ts +166 -166
  219. package/src/platform/registry/stats-types.ts +68 -68
  220. package/src/platform/registry/types.ts +425 -425
  221. package/src/platform/registry/validation.ts +745 -745
  222. package/src/platform/utils/__tests__/validation.test.ts +1084 -1084
  223. package/src/platform/utils/validation.ts +425 -425
  224. package/src/projects/api-schemas.test.ts +39 -39
  225. package/src/projects/api-schemas.ts +291 -291
  226. package/src/reference/_generated/contracts.md +2101 -2096
  227. package/src/reference/glossary.md +76 -76
  228. package/src/scaffold-registry/__tests__/index.test.ts +206 -206
  229. package/src/scaffold-registry/__tests__/schema.test.ts +166 -166
  230. package/src/scaffold-registry/index.ts +392 -392
  231. package/src/scaffold-registry/schema.ts +243 -243
  232. package/src/server.ts +289 -289
  233. package/src/supabase/database.types.ts +3153 -3153
  234. package/src/test-utils/README.md +37 -37
  235. package/src/test-utils/entities.ts +108 -108
  236. package/src/test-utils/fixtures/memberships.ts +82 -82
  237. package/src/test-utils/index.ts +12 -12
  238. package/src/test-utils/organization-model.ts +65 -65
  239. package/src/test-utils/published.ts +6 -6
  240. package/src/test-utils/rls/RLSTestContext.ts +588 -588
  241. package/src/test-utils/test-utils.test.ts +44 -44
@@ -1,266 +1,488 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { DEFAULT_ORGANIZATION_MODEL_BRANDING } from '../domains/branding'
3
+ import { resolveSystemConfig } from '../helpers'
4
+ import { compileOrganizationOntology, formatOntologyId, listResolvedOntologyRecords, parseOntologyId } from '../ontology'
5
+ import { resolveOrganizationModelWithResources } from '../resolve'
3
6
  import { OrganizationModelSchema } from '../schema'
7
+
8
+ // Phase 4 (D8): sales, prospecting, projects, navigation top-level fields removed.
9
+ // DEFAULT_ORGANIZATION_MODEL_SALES / _PROSPECTING / _PROJECTS / OrganizationModelSalesSchema etc.
10
+ // no longer exported. makeMinimalModel no longer includes those fields.
11
+ // knowledge is now a flat Record<id, OrgKnowledgeNode> (D3) — wrapper shape removed.
12
+
13
+ function makeSystem(id: string, path = `/${id.replaceAll('.', '/')}`) {
14
+ return {
15
+ id,
16
+ order: 10,
17
+ label: id,
18
+ enabled: true,
19
+ lifecycle: 'active' as const,
20
+ path
21
+ }
22
+ }
23
+
24
+ function makeMinimalModel(systems: Record<string, unknown> = {}) {
25
+ const entityOwnerId = Object.keys(systems)[0] ?? 'dashboard'
26
+ return {
27
+ version: 1 as const,
28
+ branding: DEFAULT_ORGANIZATION_MODEL_BRANDING,
29
+ // Phase 4: sales, prospecting, projects removed from top-level OM fields.
30
+ entities: {
31
+ 'crm.deal': { id: 'crm.deal', order: 10, label: 'Deal', ownedBySystemId: entityOwnerId },
32
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: entityOwnerId },
33
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: entityOwnerId },
34
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: entityOwnerId },
35
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: entityOwnerId },
36
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: entityOwnerId },
37
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: entityOwnerId }
38
+ },
39
+ systems
40
+ }
41
+ }
42
+
43
+ function getIssueMessages(data: unknown): string[] {
44
+ const result = OrganizationModelSchema.safeParse(data)
45
+ if (result.success) return []
46
+ return result.error.issues.map((issue) => issue.message)
47
+ }
48
+
49
+ describe('system tree validation', () => {
50
+ it('passes with a flat list that has declared ancestors', () => {
51
+ const model = makeMinimalModel({
52
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
53
+ 'sales.crm': makeSystem('sales.crm', '/sales/crm/pipeline'),
54
+ 'sales.lead-gen': makeSystem('sales.lead-gen', '/lead-gen/lists')
55
+ })
56
+
57
+ expect(() => OrganizationModelSchema.parse(model)).not.toThrow()
58
+ })
59
+
60
+ it('rejects duplicate system ids', () => {
61
+ // In a record, duplicate keys are impossible at the JS level.
62
+ // The schema refine checks that entry.id === mapKey, so mismatched ids are caught.
63
+ const messages = getIssueMessages(
64
+ makeMinimalModel({
65
+ sales: { id: 'NOT_sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' }
66
+ })
67
+ )
68
+
69
+ expect(messages.some((message) => message.includes('Each system entry id must match its map key'))).toBe(true)
70
+ })
71
+
72
+ it('rejects duplicate effective system paths when explicit and default paths collide', () => {
73
+ const messages = getIssueMessages(
74
+ makeMinimalModel({
75
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
76
+ 'sales.crm': makeSystem('sales.crm', '/sales/lead-gen'),
77
+ 'sales.lead-gen': { id: 'sales.lead-gen', order: 20, label: 'Lead Gen', enabled: true, lifecycle: 'active' }
78
+ })
79
+ )
80
+
81
+ expect(
82
+ messages.some((message) =>
83
+ message.includes('System "sales.lead-gen" effective path "/sales/lead-gen" duplicates system "sales.crm"')
84
+ )
85
+ ).toBe(true)
86
+ })
87
+
88
+ it('rejects a dotted child when its immediate parent is missing', () => {
89
+ const messages = getIssueMessages(
90
+ makeMinimalModel({
91
+ 'sales.crm.pipeline': { ...makeSystem('sales.crm.pipeline', '/pipeline'), parentSystemId: 'sales.crm' }
92
+ })
93
+ )
94
+
95
+ expect(messages.some((message) => message.includes('unknown parent "sales.crm"'))).toBe(true)
96
+ })
97
+
98
+ it('allows a leaf without a path so resolvers can derive a default path', () => {
99
+ const result = OrganizationModelSchema.safeParse(
100
+ makeMinimalModel({ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' } })
101
+ )
102
+
103
+ expect(result.success).toBe(true)
104
+ })
105
+
106
+ it('rejects an active container with no active descendants', () => {
107
+ const messages = getIssueMessages(
108
+ makeMinimalModel({
109
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
110
+ 'sales.crm': {
111
+ id: 'sales.crm',
112
+ order: 20,
113
+ label: 'CRM',
114
+ enabled: false,
115
+ lifecycle: 'deprecated',
116
+ path: '/sales/crm/pipeline'
117
+ }
118
+ })
119
+ )
120
+
121
+ expect(messages.some((message) => message.includes('has no active descendants'))).toBe(true)
122
+ })
123
+
124
+ it('navigation.sidebar defaults to empty sections when omitted', () => {
125
+ const model = OrganizationModelSchema.parse(makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }))
126
+
127
+ expect(model.navigation.sidebar.primary).toEqual({})
128
+ expect(model.navigation.sidebar.bottom).toEqual({})
129
+ expect('surfaces' in model).toBe(false)
130
+ expect('navigationGroups' in model).toBe(false)
131
+ expect('resourceMappings' in model).toBe(false)
132
+ })
133
+
134
+ // Phase 4: the old model.navigation.defaultSurfaceId validation no longer exists.
135
+ // Navigation defaults are now managed at the foundation layer (createFoundationOrganizationModel).
136
+ // The following tests that exercised model.navigation.* refines are skipped with a reason.
137
+ it.skip('rejects an unknown navigation default surface when surfaces are explicitly empty (deferred — Phase 4: navigation field removed)', () => {
138
+ // Previously tested: model.navigation.defaultSurfaceId referencing a missing surface.
139
+ // The navigation domain no longer exists at the top-level OM schema level.
140
+ })
141
+
142
+ it.skip('rejects navigation group surfaceIds that reference missing surfaces (deferred — Phase 4: navigation field removed)', () => {
143
+ // Previously tested: model.navigation.groups[*].surfaceIds referencing missing surfaces.
144
+ // Navigation groups are now top-level navigationGroups Record — cross-ref validation
145
+ // is handled at the resolveOrganizationModel refine layer if/when re-added.
146
+ })
147
+
148
+ it.skip('rejects navigation surface systemIds references to missing systems (deferred — Phase 4: navigation field removed)', () => {
149
+ // Previously tested: model.navigation.surfaces[*].systemIds referencing missing systems.
150
+ // Surfaces are now top-level surfaces Record — cross-ref validation is handled separately.
151
+ })
152
+
153
+ it.skip('accepts navigation systemIds when canonical systems exist (deferred — Phase 4: navigation field removed)', () => {
154
+ // Previously tested: model.navigation.surfaces[*].systemIds with valid system refs.
155
+ // See navigation.test.ts for top-level surfaces/navigationGroups coverage.
156
+ })
157
+ })
158
+
159
+ describe('domain metadata validation', () => {
160
+ it('defaults domain metadata and knowledge domain versioning', () => {
161
+ const model = OrganizationModelSchema.parse(makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }))
162
+
163
+ expect(model.domainMetadata.knowledge).toEqual({ version: 1, lastModified: '2026-05-10' })
164
+ expect(model.domainMetadata.actions).toEqual({ version: 1, lastModified: '2026-05-10' })
165
+ expect(model.domainMetadata.entities).toEqual({ version: 1, lastModified: '2026-05-10' })
166
+ expect(model.domainMetadata.policies).toEqual({ version: 1, lastModified: '2026-05-10' })
167
+ // Phase 4 (D7): knowledge version/lastModified moved to domainMetadata.knowledge.
168
+ // model.knowledge is now a flat Record<id, OrgKnowledgeNode> — no .version or .lastModified.
169
+ expect(model.domainMetadata.knowledge.version).toBe(1)
170
+ expect(model.domainMetadata.knowledge.lastModified).toBe('2026-05-10')
171
+ })
172
+
173
+ it('rejects malformed domain lastModified dates', () => {
174
+ const messages = getIssueMessages({
175
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
176
+ domainMetadata: {
177
+ knowledge: { version: 1, lastModified: '05/10/2026' }
178
+ }
179
+ })
180
+
181
+ expect(messages.some((message) => message.includes('lastModified must be an ISO date string'))).toBe(true)
182
+ })
183
+ })
4
184
 
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('.', '/')}`) {
11
- return {
12
- id,
13
- order: 10,
14
- label: id,
15
- enabled: true,
16
- lifecycle: 'active' as const,
17
- path
18
- }
19
- }
20
-
21
- function makeMinimalModel(systems: Record<string, unknown> = {}) {
22
- const entityOwnerId = Object.keys(systems)[0] ?? 'dashboard'
23
- return {
24
- version: 1 as const,
25
- branding: DEFAULT_ORGANIZATION_MODEL_BRANDING,
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
37
- }
38
- }
39
-
40
- function getIssueMessages(data: unknown): string[] {
41
- const result = OrganizationModelSchema.safeParse(data)
42
- if (result.success) return []
43
- return result.error.issues.map((issue) => issue.message)
44
- }
45
-
46
- describe('system tree validation', () => {
47
- it('passes with a flat list that has declared ancestors', () => {
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')
185
+ describe('ontology contract validation', () => {
186
+ it('parses and formats scoped and global ontology ids', () => {
187
+ expect(parseOntologyId('sales.crm:object/deal')).toEqual({
188
+ id: 'sales.crm:object/deal',
189
+ scope: 'sales.crm',
190
+ kind: 'object',
191
+ localId: 'deal',
192
+ isGlobal: false
52
193
  })
53
-
54
- expect(() => OrganizationModelSchema.parse(model)).not.toThrow()
55
- })
56
-
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
- })
194
+ expect(parseOntologyId('global:value-type/email')).toMatchObject({
195
+ scope: 'global',
196
+ kind: 'value-type',
197
+ localId: 'email',
198
+ isGlobal: true
199
+ })
200
+ expect(formatOntologyId({ scope: 'enterprise.revenue.sales.crm', kind: 'action', localId: 'deal.update-stage' })).toBe(
201
+ 'enterprise.revenue.sales.crm:action/deal.update-stage'
64
202
  )
65
-
66
- expect(messages.some((message) => message.includes('Each system entry id must match its map key'))).toBe(true)
203
+ expect(() => parseOntologyId('sales.crm/object/deal')).toThrow()
204
+ expect(() => parseOntologyId('sales.crm:unknown/deal')).toThrow()
67
205
  })
68
206
 
69
- it('rejects duplicate effective system paths when explicit and default paths collide', () => {
70
- const messages = getIssueMessages(
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
- })
76
- )
207
+ it('accepts ontology scopes at the top level and system level', () => {
208
+ const result = OrganizationModelSchema.safeParse({
209
+ ...makeMinimalModel({
210
+ sales: {
211
+ ...makeSystem('sales', '/sales'),
212
+ ontology: {
213
+ catalogTypes: {
214
+ 'sales:catalog/pipeline': {
215
+ id: 'sales:catalog/pipeline',
216
+ label: 'Pipeline',
217
+ kind: 'pipeline',
218
+ ownerSystemId: 'sales'
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }),
224
+ ontology: {
225
+ valueTypes: {
226
+ 'global:value-type/email': {
227
+ id: 'global:value-type/email',
228
+ label: 'Email',
229
+ primitive: 'string'
230
+ }
231
+ }
232
+ }
233
+ })
77
234
 
78
- expect(
79
- messages.some((message) =>
80
- message.includes('System "sales.lead-gen" effective path "/sales/lead-gen" duplicates system "sales.crm"')
81
- )
82
- ).toBe(true)
235
+ expect(result.success).toBe(true)
83
236
  })
84
237
 
85
- it('rejects a dotted child when its immediate parent is missing', () => {
86
- const messages = getIssueMessages(
87
- makeMinimalModel({
88
- 'sales.crm.pipeline': { ...makeSystem('sales.crm.pipeline', '/pipeline'), parentSystemId: 'sales.crm' }
89
- })
90
- )
238
+ it('reports invalid and wrong-scope ontology ids with diagnostic context', () => {
239
+ const compilation = compileOrganizationOntology({
240
+ ontology: {
241
+ objectTypes: {
242
+ 'dashboard:object/deal': {
243
+ id: 'Bad Ontology ID',
244
+ label: 'Bad ID'
245
+ },
246
+ 'dashboard:action/send-reply': {
247
+ id: 'dashboard:action/send-reply',
248
+ label: 'Misfiled Action'
249
+ }
250
+ }
251
+ }
252
+ })
91
253
 
92
- expect(messages.some((message) => message.includes('unknown parent "sales.crm"'))).toBe(true)
254
+ expect(compilation.diagnostics).toEqual(
255
+ expect.arrayContaining([
256
+ expect.objectContaining({
257
+ code: 'invalid_ontology_id',
258
+ id: 'Bad Ontology ID',
259
+ source: 'organization.ontology',
260
+ path: ['ontology', 'objectTypes', 'dashboard:object/deal'],
261
+ origin: expect.objectContaining({ kind: 'authored', source: 'organization.ontology' })
262
+ }),
263
+ expect.objectContaining({
264
+ code: 'ontology_kind_mismatch',
265
+ id: 'dashboard:action/send-reply',
266
+ source: 'organization.ontology',
267
+ path: ['ontology', 'objectTypes', 'dashboard:action/send-reply'],
268
+ origin: expect.objectContaining({ kind: 'authored', source: 'organization.ontology' })
269
+ })
270
+ ])
271
+ )
93
272
  })
94
273
 
95
- it('allows a leaf without a path so resolvers can derive a default path', () => {
96
- const result = OrganizationModelSchema.safeParse(
97
- makeMinimalModel({ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' } })
98
- )
274
+ it('returns deterministic compiled ontology indexes and record lists', () => {
275
+ const compilation = compileOrganizationOntology({
276
+ ontology: {
277
+ objectTypes: {
278
+ 'dashboard:object/z-last': {
279
+ id: 'dashboard:object/z-last',
280
+ label: 'Z Last'
281
+ },
282
+ 'dashboard:object/a-first': {
283
+ id: 'dashboard:object/a-first',
284
+ label: 'A First'
285
+ }
286
+ },
287
+ actionTypes: {
288
+ 'dashboard:action/z-last': {
289
+ id: 'dashboard:action/z-last',
290
+ label: 'Z Last'
291
+ },
292
+ 'dashboard:action/a-first': {
293
+ id: 'dashboard:action/a-first',
294
+ label: 'A First'
295
+ }
296
+ }
297
+ }
298
+ })
99
299
 
100
- expect(result.success).toBe(true)
300
+ expect(Object.keys(compilation.ontology.objectTypes)).toEqual([
301
+ 'dashboard:object/a-first',
302
+ 'dashboard:object/z-last'
303
+ ])
304
+ expect(Object.keys(compilation.ontology.actionTypes)).toEqual([
305
+ 'dashboard:action/a-first',
306
+ 'dashboard:action/z-last'
307
+ ])
308
+ expect(listResolvedOntologyRecords(compilation.ontology).map((entry) => entry.id)).toEqual([
309
+ 'dashboard:object/a-first',
310
+ 'dashboard:object/z-last',
311
+ 'dashboard:action/a-first',
312
+ 'dashboard:action/z-last'
313
+ ])
101
314
  })
102
315
 
103
- it('rejects an active container with no active descendants', () => {
104
- const messages = getIssueMessages(
316
+ it('projects legacy entities, actions, and system content into compiled ontology indexes', () => {
317
+ const model = OrganizationModelSchema.parse(
105
318
  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'
319
+ dashboard: {
320
+ ...makeSystem('dashboard', '/'),
321
+ content: {
322
+ pipeline: {
323
+ kind: 'schema',
324
+ type: 'pipeline',
325
+ label: 'Pipeline',
326
+ data: { entityId: 'crm.deal' }
327
+ }
328
+ }
114
329
  }
115
330
  })
116
331
  )
117
332
 
118
- expect(messages.some((message) => message.includes('has no active descendants'))).toBe(true)
119
- })
333
+ const compilation = compileOrganizationOntology(model)
120
334
 
121
- it('navigation.sidebar defaults to empty sections when omitted', () => {
122
- const model = OrganizationModelSchema.parse(makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }))
123
-
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)
128
- expect('resourceMappings' in model).toBe(false)
129
- })
130
-
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.
335
+ expect(compilation.diagnostics).toEqual([])
336
+ expect(compilation.ontology.objectTypes['dashboard:object/crm.deal']?.legacyEntityId).toBe('crm.deal')
337
+ expect(compilation.ontology.actionTypes['dashboard:action/send_reply']?.legacyActionId).toBe('send_reply')
338
+ expect(compilation.ontology.catalogTypes['dashboard:catalog/pipeline']?.legacyContentId).toBe('dashboard:pipeline')
143
339
  })
144
340
 
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
- })
341
+ it('adds origin metadata to authored and projected compiled ontology records without mutating source', () => {
342
+ const authoredObject = {
343
+ id: 'dashboard:object/task',
344
+ label: 'Task',
345
+ ownerSystemId: 'dashboard'
346
+ }
347
+ const model = OrganizationModelSchema.parse({
348
+ ...makeMinimalModel({
349
+ dashboard: {
350
+ ...makeSystem('dashboard', '/'),
351
+ ontology: {
352
+ objectTypes: {
353
+ 'dashboard:object/task': authoredObject
354
+ }
355
+ },
356
+ content: {
357
+ pipeline: {
358
+ kind: 'schema',
359
+ type: 'pipeline',
360
+ label: 'Pipeline',
361
+ data: { entityId: 'crm.deal' }
362
+ }
363
+ }
364
+ }
365
+ })
366
+ })
155
367
 
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
- })
368
+ const compilation = compileOrganizationOntology(model)
169
369
 
170
- it('rejects malformed domain lastModified dates', () => {
171
- const messages = getIssueMessages({
172
- ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
173
- domainMetadata: {
174
- knowledge: { version: 1, lastModified: '05/10/2026' }
175
- }
370
+ expect(compilation.ontology.objectTypes['dashboard:object/task']?.origin).toMatchObject({
371
+ kind: 'authored',
372
+ source: 'system:dashboard.ontology',
373
+ systemPath: 'dashboard'
176
374
  })
177
-
178
- expect(messages.some((message) => message.includes('lastModified must be an ISO date string'))).toBe(true)
375
+ expect(compilation.ontology.catalogTypes['dashboard:catalog/pipeline']?.origin).toMatchObject({
376
+ kind: 'projected',
377
+ source: 'legacy.system.content',
378
+ systemPath: 'dashboard',
379
+ legacyId: 'dashboard:pipeline'
380
+ })
381
+ expect(authoredObject).not.toHaveProperty('origin')
382
+ expect(model.systems.dashboard?.ontology?.objectTypes?.['dashboard:object/task']).not.toHaveProperty('origin')
179
383
  })
180
- })
181
384
 
182
- describe('entity validation', () => {
183
- it('rejects entities owned by unknown systems', () => {
385
+ it('rejects duplicate ontology ids with source context', () => {
184
386
  const messages = getIssueMessages({
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' }
387
+ ...makeMinimalModel({
388
+ dashboard: makeSystem('dashboard', '/')
389
+ }),
390
+ ontology: {
391
+ objectTypes: {
392
+ 'dashboard:object/crm.deal': {
393
+ id: 'dashboard:object/crm.deal',
394
+ label: 'Duplicate Deal',
395
+ ownerSystemId: 'dashboard'
396
+ }
397
+ }
194
398
  }
195
399
  })
196
400
 
197
- expect(messages.some((message) => message.includes('references unknown ownedBySystemId "missing.system"'))).toBe(
401
+ expect(messages.some((message) => message.includes('Duplicate ontology ID "dashboard:object/crm.deal"'))).toBe(
198
402
  true
199
403
  )
404
+ expect(messages.some((message) => message.includes('legacy.entities'))).toBe(true)
200
405
  })
201
406
 
202
- it('rejects entity links to unknown entities', () => {
203
- const messages = getIssueMessages({
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' }
407
+ it('keeps duplicate diagnostics origin-aware for authored versus projected records', () => {
408
+ const model = OrganizationModelSchema.parse(
409
+ makeMinimalModel({
410
+ dashboard: makeSystem('dashboard', '/')
411
+ })
412
+ )
413
+ const compilation = compileOrganizationOntology({
414
+ ...model,
415
+ ontology: {
416
+ objectTypes: {
417
+ 'dashboard:object/crm.deal': {
418
+ id: 'dashboard:object/crm.deal',
419
+ label: 'Duplicate Deal',
420
+ ownerSystemId: 'dashboard'
421
+ }
422
+ }
219
423
  }
220
424
  })
221
425
 
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.
426
+ expect(compilation.diagnostics[0]).toMatchObject({
427
+ code: 'duplicate_ontology_id',
428
+ origin: { kind: 'projected', source: 'legacy.entities', legacyId: 'crm.deal' },
429
+ existingOrigin: { kind: 'authored', source: 'organization.ontology' }
430
+ })
230
431
  })
231
- })
232
432
 
233
- describe('system action and policy validation', () => {
234
- it('accepts systems that attach known actions and policies', () => {
433
+ it('accepts resource ontology bindings when referenced ontology records exist', () => {
235
434
  const result = OrganizationModelSchema.safeParse({
236
435
  ...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']
436
+ dashboard: {
437
+ ...makeSystem('dashboard', '/'),
438
+ ontology: {
439
+ objectTypes: {
440
+ 'dashboard:object/task': {
441
+ id: 'dashboard:object/task',
442
+ label: 'Task',
443
+ ownerSystemId: 'dashboard'
444
+ }
445
+ },
446
+ actionTypes: {
447
+ 'dashboard:action/process-task': {
448
+ id: 'dashboard:action/process-task',
449
+ label: 'Process Task',
450
+ ownerSystemId: 'dashboard',
451
+ actsOn: ['dashboard:object/task']
452
+ }
453
+ },
454
+ catalogTypes: {
455
+ 'dashboard:catalog/task-status': {
456
+ id: 'dashboard:catalog/task-status',
457
+ label: 'Task Status',
458
+ ownerSystemId: 'dashboard',
459
+ appliesTo: 'dashboard:object/task'
460
+ }
461
+ },
462
+ eventTypes: {
463
+ 'dashboard:event/task-processed': {
464
+ id: 'dashboard:event/task-processed',
465
+ label: 'Task Processed',
466
+ ownerSystemId: 'dashboard'
467
+ }
468
+ }
469
+ }
254
470
  }
255
- },
256
- policies: {
257
- 'policy.lead-gen.approval': {
258
- id: 'policy.lead-gen.approval',
471
+ }),
472
+ resources: {
473
+ 'task-processor': {
474
+ id: 'task-processor',
259
475
  order: 10,
260
- label: 'Lead Gen Approval',
261
- trigger: { kind: 'manual' },
262
- actions: [{ kind: 'block' }],
263
- appliesTo: { systemIds: ['sales.lead-gen'] }
476
+ kind: 'workflow',
477
+ systemPath: 'dashboard',
478
+ status: 'active',
479
+ ontology: {
480
+ implements: ['dashboard:action/process-task'],
481
+ reads: ['dashboard:object/task'],
482
+ writes: ['dashboard:object/task'],
483
+ usesCatalogs: ['dashboard:catalog/task-status'],
484
+ emits: ['dashboard:event/task-processed']
485
+ }
264
486
  }
265
487
  }
266
488
  })
@@ -268,115 +490,426 @@ describe('system action and policy validation', () => {
268
490
  expect(result.success).toBe(true)
269
491
  })
270
492
 
271
- it('rejects systems that attach unknown actions or policies', () => {
272
- const messages = getIssueMessages({
493
+ it('accepts known ontology references inside loose property, input, effect, entry, and payload fields', () => {
494
+ const result = OrganizationModelSchema.safeParse({
273
495
  ...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']
496
+ dashboard: {
497
+ ...makeSystem('dashboard', '/'),
498
+ ontology: {
499
+ objectTypes: {
500
+ 'dashboard:object/task': {
501
+ id: 'dashboard:object/task',
502
+ label: 'Task',
503
+ ownerSystemId: 'dashboard',
504
+ properties: {
505
+ id: { valueType: 'global:value-type/uuid' },
506
+ status: { catalogType: 'dashboard:catalog/task-status' }
507
+ }
508
+ }
509
+ },
510
+ actionTypes: {
511
+ 'dashboard:action/process-task': {
512
+ id: 'dashboard:action/process-task',
513
+ label: 'Process Task',
514
+ ownerSystemId: 'dashboard',
515
+ actsOn: ['dashboard:object/task'],
516
+ input: {
517
+ status: { catalogType: 'dashboard:catalog/task-status' }
518
+ },
519
+ effects: [
520
+ { kind: 'write', objectType: 'dashboard:object/task' },
521
+ { kind: 'emitEvent', eventType: 'dashboard:event/task-processed' }
522
+ ]
523
+ }
524
+ },
525
+ catalogTypes: {
526
+ 'dashboard:catalog/task-status': {
527
+ id: 'dashboard:catalog/task-status',
528
+ label: 'Task Status',
529
+ ownerSystemId: 'dashboard',
530
+ appliesTo: 'dashboard:object/task',
531
+ entries: {
532
+ todo: { label: 'To Do' },
533
+ done: { label: 'Done', stepCatalog: 'dashboard:catalog/task-status' }
534
+ }
535
+ }
536
+ },
537
+ eventTypes: {
538
+ 'dashboard:event/task-processed': {
539
+ id: 'dashboard:event/task-processed',
540
+ label: 'Task Processed',
541
+ ownerSystemId: 'dashboard',
542
+ payload: {
543
+ taskId: { valueType: 'global:value-type/uuid' },
544
+ status: { catalogType: 'dashboard:catalog/task-status' }
545
+ }
546
+ }
547
+ }
548
+ }
291
549
  }
292
- }
550
+ })
293
551
  })
294
552
 
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)
553
+ expect(result.success).toBe(true)
297
554
  })
298
- })
299
555
 
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
- }
313
-
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', () => {
318
- const result = OrganizationModelSchema.safeParse({
556
+ it('rejects known ontology reference keys that point at missing records', () => {
557
+ const messages = getIssueMessages({
319
558
  ...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()
325
- }
559
+ dashboard: {
560
+ ...makeSystem('dashboard', '/'),
561
+ ontology: {
562
+ objectTypes: {
563
+ 'dashboard:object/task': {
564
+ id: 'dashboard:object/task',
565
+ label: 'Task',
566
+ ownerSystemId: 'dashboard',
567
+ properties: {
568
+ badValue: { valueType: 'global:value-type/missing' },
569
+ badCatalog: { catalogType: 'dashboard:catalog/missing' }
570
+ }
571
+ }
572
+ },
573
+ actionTypes: {
574
+ 'dashboard:action/process-task': {
575
+ id: 'dashboard:action/process-task',
576
+ label: 'Process Task',
577
+ ownerSystemId: 'dashboard',
578
+ effects: [{ kind: 'emitEvent', eventType: 'dashboard:event/missing' }]
579
+ }
580
+ }
581
+ }
582
+ }
583
+ })
326
584
  })
327
585
 
328
- expect(result.success).toBe(true)
586
+ expect(messages.some((message) => message.includes('valueType references unknown value-type ontology ID'))).toBe(
587
+ true
588
+ )
589
+ expect(messages.some((message) => message.includes('catalogType references unknown catalog ontology ID'))).toBe(
590
+ true
591
+ )
592
+ expect(messages.some((message) => message.includes('eventType references unknown event ontology ID'))).toBe(true)
329
593
  })
330
594
 
331
- it('rejects typed knowledge links to unknown modeled targets', () => {
595
+ it('rejects resource ontology bindings to missing compiled ontology records', () => {
332
596
  const messages = getIssueMessages({
333
597
  ...makeMinimalModel({
334
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
335
- 'sales.crm': makeSystem('sales.crm', '/crm')
598
+ dashboard: makeSystem('dashboard', '/')
336
599
  }),
337
- knowledge: {
338
- 'knowledge.sales-crm-playbook': makeKnowledgeNode({
339
- links: [{ target: { kind: 'resource', id: 'missing.workflow' } }]
340
- })
600
+ resources: {
601
+ 'task-processor': {
602
+ id: 'task-processor',
603
+ order: 10,
604
+ kind: 'workflow',
605
+ systemPath: 'dashboard',
606
+ status: 'active',
607
+ ontology: {
608
+ implements: ['dashboard:action/missing-task'],
609
+ reads: ['dashboard:object/missing-task'],
610
+ usesCatalogs: ['dashboard:catalog/missing-status'],
611
+ emits: ['dashboard:event/missing-event']
612
+ }
613
+ }
341
614
  }
342
615
  })
343
616
 
344
- expect(messages.some((message) => message.includes('references unknown resource target "missing.workflow"'))).toBe(
345
- true
346
- )
617
+ expect(messages.some((message) => message.includes('references unknown action ontology ID'))).toBe(true)
618
+ expect(messages.some((message) => message.includes('references unknown object ontology ID'))).toBe(true)
619
+ expect(messages.some((message) => message.includes('references unknown catalog ontology ID'))).toBe(true)
620
+ expect(messages.some((message) => message.includes('references unknown event ontology ID'))).toBe(true)
347
621
  })
622
+ })
348
623
 
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
- }
624
+ describe('system config contract', () => {
625
+ it('accepts authored system-local JSON config', () => {
626
+ const model = OrganizationModelSchema.parse(
627
+ makeMinimalModel({
628
+ dashboard: {
629
+ ...makeSystem('dashboard', '/'),
630
+ config: {
631
+ defaultPipelineId: 'sales.crm:catalog/pipeline',
632
+ kanban: {
633
+ groupBy: 'stage',
634
+ showProbability: true
635
+ },
636
+ retryDelays: [100, 500, null]
637
+ }
638
+ }
639
+ })
640
+ )
641
+
642
+ expect(model.systems.dashboard?.config).toEqual({
643
+ defaultPipelineId: 'sales.crm:catalog/pipeline',
644
+ kanban: {
645
+ groupBy: 'stage',
646
+ showProbability: true
647
+ },
648
+ retryDelays: [100, 500, null]
361
649
  })
650
+ })
362
651
 
363
- expect(result.success).toBe(true)
652
+ it('rejects non-JSON system config values', () => {
653
+ const result = OrganizationModelSchema.safeParse(
654
+ makeMinimalModel({
655
+ dashboard: {
656
+ ...makeSystem('dashboard', '/'),
657
+ config: {
658
+ callback: () => true
659
+ }
660
+ }
661
+ })
662
+ )
663
+
664
+ expect(result.success).toBe(false)
364
665
  })
365
666
 
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
- }
667
+ it('projects bridge-era config:kv into effective resolved system config', () => {
668
+ const model = OrganizationModelSchema.parse(
669
+ makeMinimalModel({
670
+ dashboard: {
671
+ ...makeSystem('dashboard', '/'),
672
+ config: {
673
+ retries: 3,
674
+ nested: { direct: true }
675
+ },
676
+ content: {
677
+ settings: {
678
+ kind: 'config',
679
+ type: 'kv',
680
+ label: 'Settings',
681
+ data: { entries: { enabled: true, retries: 1, mode: 'bridge' } }
682
+ }
683
+ }
684
+ }
685
+ })
686
+ )
687
+
688
+ expect(resolveSystemConfig(model, 'dashboard')).toEqual({
689
+ enabled: true,
690
+ retries: 3,
691
+ mode: 'bridge',
692
+ nested: { direct: true }
378
693
  })
379
694
 
380
- expect(messages.some((message) => message.includes('kind "playbook" cannot govern goal targets'))).toBe(true)
695
+ const resolved = resolveOrganizationModelWithResources({
696
+ systems: {
697
+ dashboard: {
698
+ ...makeSystem('dashboard', '/'),
699
+ config: { retries: 3 },
700
+ content: {
701
+ settings: {
702
+ kind: 'config',
703
+ type: 'kv',
704
+ label: 'Settings',
705
+ data: { entries: { enabled: true, retries: 1 } }
706
+ }
707
+ }
708
+ }
709
+ }
710
+ })
711
+ expect(resolved.systems.dashboard?.config).toEqual({ enabled: true, retries: 3 })
381
712
  })
382
713
  })
714
+
715
+ describe('entity validation', () => {
716
+ it('rejects entities owned by unknown systems', () => {
717
+ const messages = getIssueMessages({
718
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
719
+ entities: {
720
+ 'crm.deal': { id: 'crm.deal', order: 10, label: 'Deal', ownedBySystemId: 'missing.system' },
721
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
722
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
723
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
724
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
725
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
726
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
727
+ }
728
+ })
729
+
730
+ expect(messages.some((message) => message.includes('references unknown ownedBySystemId "missing.system"'))).toBe(
731
+ true
732
+ )
733
+ })
734
+
735
+ it('rejects entity links to unknown entities', () => {
736
+ const messages = getIssueMessages({
737
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
738
+ entities: {
739
+ 'crm.deal': {
740
+ id: 'crm.deal',
741
+ order: 10,
742
+ label: 'Deal',
743
+ ownedBySystemId: 'dashboard',
744
+ links: [{ toEntity: 'missing.entity', kind: 'belongs-to' }]
745
+ },
746
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
747
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
748
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
749
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
750
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
751
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
752
+ }
753
+ })
754
+
755
+ expect(messages.some((message) => message.includes('links to unknown entity "missing.entity"'))).toBe(true)
756
+ })
757
+
758
+ // Phase 4: Prospecting.contactEntityId validation removed (prospecting domain deleted).
759
+ // The separate test for prospecting entity refs is skipped below.
760
+ it.skip('rejects prospecting contactEntityId referencing unknown entity (deferred — Phase 4: prospecting domain removed)', () => {
761
+ // Previously tested: model.prospecting.contactEntityId referencing a missing entity.
762
+ // The prospecting domain no longer exists at the top-level OM schema level.
763
+ })
764
+ })
765
+
766
+ describe('system action and policy validation', () => {
767
+ it('accepts systems that attach known actions and policies', () => {
768
+ const result = OrganizationModelSchema.safeParse({
769
+ ...makeMinimalModel({
770
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
771
+ 'sales.lead-gen': makeSystem('sales.lead-gen')
772
+ }),
773
+ systems: {
774
+ sales: {
775
+ id: 'sales',
776
+ order: 10,
777
+ label: 'Sales',
778
+ lifecycle: 'active'
779
+ },
780
+ 'sales.lead-gen': {
781
+ id: 'sales.lead-gen',
782
+ order: 20,
783
+ label: 'Lead Gen',
784
+ lifecycle: 'active',
785
+ actions: [{ actionId: 'lead-gen.company.source', intent: 'exposes' }],
786
+ policies: ['policy.lead-gen.approval']
787
+ }
788
+ },
789
+ policies: {
790
+ 'policy.lead-gen.approval': {
791
+ id: 'policy.lead-gen.approval',
792
+ order: 10,
793
+ label: 'Lead Gen Approval',
794
+ trigger: { kind: 'manual' },
795
+ actions: [{ kind: 'block' }],
796
+ appliesTo: { systemIds: ['sales.lead-gen'] }
797
+ }
798
+ }
799
+ })
800
+
801
+ expect(result.success).toBe(true)
802
+ })
803
+
804
+ it('rejects systems that attach unknown actions or policies', () => {
805
+ const messages = getIssueMessages({
806
+ ...makeMinimalModel({
807
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
808
+ 'sales.lead-gen': makeSystem('sales.lead-gen')
809
+ }),
810
+ systems: {
811
+ sales: {
812
+ id: 'sales',
813
+ order: 10,
814
+ label: 'Sales',
815
+ lifecycle: 'active'
816
+ },
817
+ 'sales.lead-gen': {
818
+ id: 'sales.lead-gen',
819
+ order: 20,
820
+ label: 'Lead Gen',
821
+ lifecycle: 'active',
822
+ actions: [{ actionId: 'missing.action', intent: 'exposes' }],
823
+ policies: ['missing.policy']
824
+ }
825
+ }
826
+ })
827
+
828
+ expect(messages.some((message) => message.includes('references unknown action "missing.action"'))).toBe(true)
829
+ expect(messages.some((message) => message.includes('references unknown policy "missing.policy"'))).toBe(true)
830
+ })
831
+ })
832
+
833
+ describe('knowledge governs validation', () => {
834
+ function makeKnowledgeNode(overrides: Record<string, unknown> = {}) {
835
+ return {
836
+ id: 'knowledge.sales-crm-playbook',
837
+ kind: 'playbook' as const,
838
+ title: 'Sales CRM Playbook',
839
+ summary: 'How CRM work is governed.',
840
+ body: '## CRM',
841
+ updatedAt: '2026-05-10',
842
+ links: [{ target: { kind: 'system', id: 'sales.crm' } }],
843
+ ...overrides
844
+ }
845
+ }
846
+
847
+ // Phase 4 (D3): knowledge is now a flat Record<id, OrgKnowledgeNode>.
848
+ // Fixtures updated from { nodes: [...] } to { 'knowledge.id': {...node} }.
849
+
850
+ it('accepts typed knowledge links when the target exists', () => {
851
+ const result = OrganizationModelSchema.safeParse({
852
+ ...makeMinimalModel({
853
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
854
+ 'sales.crm': makeSystem('sales.crm', '/crm')
855
+ }),
856
+ knowledge: {
857
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode()
858
+ }
859
+ })
860
+
861
+ expect(result.success).toBe(true)
862
+ })
863
+
864
+ it('rejects typed knowledge links to unknown modeled targets', () => {
865
+ const messages = getIssueMessages({
866
+ ...makeMinimalModel({
867
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
868
+ 'sales.crm': makeSystem('sales.crm', '/crm')
869
+ }),
870
+ knowledge: {
871
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
872
+ links: [{ target: { kind: 'resource', id: 'missing.workflow' } }]
873
+ })
874
+ }
875
+ })
876
+
877
+ expect(messages.some((message) => message.includes('references unknown resource target "missing.workflow"'))).toBe(
878
+ true
879
+ )
880
+ })
881
+
882
+ it('allows strategy nodes to target systems', () => {
883
+ const result = OrganizationModelSchema.safeParse({
884
+ ...makeMinimalModel({
885
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
886
+ 'sales.crm': makeSystem('sales.crm', '/crm')
887
+ }),
888
+ knowledge: {
889
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
890
+ kind: 'strategy',
891
+ links: [{ target: { kind: 'system', id: 'sales.crm' } }]
892
+ })
893
+ }
894
+ })
895
+
896
+ expect(result.success).toBe(true)
897
+ })
898
+
899
+ it('rejects incompatible knowledge kind to target kind pairings', () => {
900
+ const messages = getIssueMessages({
901
+ ...makeMinimalModel({
902
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
903
+ 'sales.crm': makeSystem('sales.crm', '/crm')
904
+ }),
905
+ knowledge: {
906
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
907
+ kind: 'playbook',
908
+ links: [{ target: { kind: 'goal', id: 'missing.goal' } }]
909
+ })
910
+ }
911
+ })
912
+
913
+ expect(messages.some((message) => message.includes('kind "playbook" cannot govern goal targets'))).toBe(true)
914
+ })
915
+ })