@elevasis/core 0.22.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 (244) hide show
  1. package/dist/index.d.ts +3214 -2501
  2. package/dist/index.js +3112 -1222
  3. package/dist/knowledge/index.d.ts +1108 -1264
  4. package/dist/knowledge/index.js +112 -9
  5. package/dist/organization-model/index.d.ts +3214 -2501
  6. package/dist/organization-model/index.js +3112 -1222
  7. package/dist/test-utils/index.d.ts +985 -1103
  8. package/dist/test-utils/index.js +2464 -1165
  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 -80
  13. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +2389 -2121
  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 +1493 -1500
  37. package/src/business/acquisition/build-templates.test.ts +240 -240
  38. package/src/business/acquisition/build-templates.ts +83 -41
  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 -151
  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 -392
  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 +33 -32
  123. package/src/knowledge/__tests__/queries.test.ts +633 -541
  124. package/src/knowledge/format.ts +100 -99
  125. package/src/knowledge/index.ts +5 -5
  126. package/src/knowledge/published.ts +5 -5
  127. package/src/knowledge/queries.ts +274 -222
  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 -109
  131. package/src/organization-model/__tests__/content-kinds-registry.test.ts +210 -0
  132. package/src/organization-model/__tests__/defaults.test.ts +168 -194
  133. package/src/organization-model/__tests__/domains/actions.test.ts +78 -0
  134. package/src/organization-model/__tests__/domains/customers.test.ts +48 -44
  135. package/src/organization-model/__tests__/domains/entities.test.ts +56 -0
  136. package/src/organization-model/__tests__/domains/goals.test.ts +110 -96
  137. package/src/organization-model/__tests__/domains/identity.test.ts +4 -3
  138. package/src/organization-model/__tests__/domains/navigation.test.ts +222 -166
  139. package/src/organization-model/__tests__/domains/offerings.test.ts +83 -88
  140. package/src/organization-model/__tests__/domains/policies.test.ts +323 -0
  141. package/src/organization-model/__tests__/domains/resource-mappings.test.ts +30 -30
  142. package/src/organization-model/__tests__/domains/resources.test.ts +396 -175
  143. package/src/organization-model/__tests__/domains/roles.test.ts +463 -402
  144. package/src/organization-model/__tests__/domains/statuses.test.ts +13 -10
  145. package/src/organization-model/__tests__/domains/systems.test.ts +209 -193
  146. package/src/organization-model/__tests__/flatten-additive-merge.test.ts +362 -0
  147. package/src/organization-model/__tests__/foundation.test.ts +47 -75
  148. package/src/organization-model/__tests__/get-resources-for-system.test.ts +144 -0
  149. package/src/organization-model/__tests__/graph.test.ts +1336 -149
  150. package/src/organization-model/__tests__/icons.test.ts +10 -1
  151. package/src/organization-model/__tests__/knowledge.test.ts +418 -61
  152. package/src/organization-model/__tests__/lookup-helpers.test.ts +438 -0
  153. package/src/organization-model/__tests__/migration-helpers.test.ts +591 -0
  154. package/src/organization-model/__tests__/prospecting-ssot.test.ts +103 -94
  155. package/src/organization-model/__tests__/recursive-system-schema.test.ts +549 -0
  156. package/src/organization-model/__tests__/resolve.test.ts +303 -42
  157. package/src/organization-model/__tests__/schema.test.ts +863 -153
  158. package/src/organization-model/__tests__/surface-projection.test.ts +284 -174
  159. package/src/organization-model/catalogs/lead-gen.ts +144 -0
  160. package/src/organization-model/content-kinds/config.ts +36 -0
  161. package/src/organization-model/content-kinds/index.ts +78 -0
  162. package/src/organization-model/content-kinds/pipeline.ts +68 -0
  163. package/src/organization-model/content-kinds/registry.ts +44 -0
  164. package/src/organization-model/content-kinds/status.ts +71 -0
  165. package/src/organization-model/content-kinds/template.ts +83 -0
  166. package/src/organization-model/content-kinds/types.ts +117 -0
  167. package/src/organization-model/contracts.ts +27 -17
  168. package/src/organization-model/defaults.ts +489 -107
  169. package/src/organization-model/domains/actions.ts +333 -0
  170. package/src/organization-model/domains/customers.ts +10 -7
  171. package/src/organization-model/domains/entities.ts +144 -0
  172. package/src/organization-model/domains/goals.ts +9 -6
  173. package/src/organization-model/domains/knowledge.ts +128 -54
  174. package/src/organization-model/domains/navigation.ts +139 -416
  175. package/src/organization-model/domains/offerings.ts +15 -10
  176. package/src/organization-model/domains/policies.ts +102 -0
  177. package/src/organization-model/domains/projects.ts +6 -40
  178. package/src/organization-model/domains/prospecting.ts +395 -514
  179. package/src/organization-model/domains/resources.ts +173 -81
  180. package/src/organization-model/domains/roles.ts +96 -93
  181. package/src/organization-model/domains/sales.test.ts +218 -218
  182. package/src/organization-model/domains/sales.ts +380 -589
  183. package/src/organization-model/domains/shared.ts +8 -8
  184. package/src/organization-model/domains/statuses.ts +298 -89
  185. package/src/organization-model/domains/systems.ts +240 -38
  186. package/src/organization-model/foundation.ts +35 -48
  187. package/src/organization-model/graph/build.ts +1035 -279
  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 +77 -56
  191. package/src/organization-model/graph/types.ts +75 -56
  192. package/src/organization-model/helpers.ts +312 -59
  193. package/src/organization-model/icons.ts +78 -66
  194. package/src/organization-model/index.ts +129 -16
  195. package/src/organization-model/migration-helpers.ts +252 -0
  196. package/src/organization-model/ontology.ts +661 -0
  197. package/src/organization-model/organization-graph.mdx +110 -89
  198. package/src/organization-model/organization-model.mdx +226 -171
  199. package/src/organization-model/published.ts +295 -139
  200. package/src/organization-model/resolve.ts +139 -21
  201. package/src/organization-model/schema.ts +841 -301
  202. package/src/organization-model/surface-projection.ts +212 -218
  203. package/src/organization-model/types.ts +181 -90
  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 +5 -7
  208. package/src/platform/registry/__tests__/resource-link.test.ts +35 -30
  209. package/src/platform/registry/__tests__/resource-registry.integration.test.ts +17 -32
  210. package/src/platform/registry/__tests__/resource-registry.nested-systems.test.ts +245 -0
  211. package/src/platform/registry/__tests__/resource-registry.test.ts +2053 -2051
  212. package/src/platform/registry/__tests__/validation.test.ts +1347 -1343
  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 -878
  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 -743
  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 +2389 -2121
  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 -3093
  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 -49
  242. package/src/organization-model/__tests__/domains/operations.test.ts +0 -203
  243. package/src/organization-model/domains/features.ts +0 -31
  244. package/src/organization-model/domains/operations.ts +0 -85
@@ -1,205 +1,915 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { DEFAULT_ORGANIZATION_MODEL_BRANDING } from '../domains/branding'
3
- import { DEFAULT_ORGANIZATION_MODEL_SALES } from '../domains/sales'
4
- import { DEFAULT_ORGANIZATION_MODEL_PROJECTS } from '../domains/projects'
5
- import { DEFAULT_ORGANIZATION_MODEL_PROSPECTING } from '../domains/prospecting'
3
+ import { resolveSystemConfig } from '../helpers'
4
+ import { compileOrganizationOntology, formatOntologyId, listResolvedOntologyRecords, parseOntologyId } from '../ontology'
5
+ import { resolveOrganizationModelWithResources } from '../resolve'
6
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
+ })
7
184
 
8
- function makeFeature(id: string, path = `/${id.replaceAll('.', '/')}`) {
9
- return {
10
- id,
11
- label: id,
12
- enabled: true,
13
- path
14
- }
15
- }
16
-
17
- function makeSurface(id: string, featureId?: string, featureIds: string[] = []) {
18
- return {
19
- id,
20
- label: id,
21
- path: `/${id.replaceAll('.', '/')}`,
22
- surfaceType: 'page' as const,
23
- featureId,
24
- featureIds,
25
- entityIds: [],
26
- resourceIds: [],
27
- capabilityIds: []
28
- }
29
- }
30
-
31
- function makeMinimalModel(features: unknown[] = []) {
32
- return {
33
- version: 1 as const,
34
- features,
35
- branding: DEFAULT_ORGANIZATION_MODEL_BRANDING,
36
- sales: DEFAULT_ORGANIZATION_MODEL_SALES,
37
- prospecting: DEFAULT_ORGANIZATION_MODEL_PROSPECTING,
38
- projects: DEFAULT_ORGANIZATION_MODEL_PROJECTS
39
- }
40
- }
41
-
42
- function getIssueMessages(data: unknown): string[] {
43
- const result = OrganizationModelSchema.safeParse(data)
44
- if (result.success) return []
45
- return result.error.issues.map((issue) => issue.message)
46
- }
47
-
48
- describe('flat feature tree validation', () => {
49
- it('passes with a flat list that has declared ancestors', () => {
50
- const model = makeMinimalModel([
51
- { id: 'sales', label: 'Sales', enabled: true },
52
- makeFeature('sales.crm', '/sales/crm/pipeline'),
53
- makeFeature('sales.lead-gen', '/lead-gen/lists')
54
- ])
55
-
56
- expect(() => OrganizationModelSchema.parse(model)).not.toThrow()
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
193
+ })
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'
202
+ )
203
+ expect(() => parseOntologyId('sales.crm/object/deal')).toThrow()
204
+ expect(() => parseOntologyId('sales.crm:unknown/deal')).toThrow()
57
205
  })
58
206
 
59
- it('rejects duplicate feature ids', () => {
60
- const messages = getIssueMessages(makeMinimalModel([makeFeature('sales'), makeFeature('sales')]))
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
+ })
61
234
 
62
- expect(messages.some((message) => message.includes('Feature id "sales" must be unique'))).toBe(true)
235
+ expect(result.success).toBe(true)
63
236
  })
64
237
 
65
- it('rejects duplicate effective feature paths when explicit and default paths collide', () => {
66
- const messages = getIssueMessages(
67
- makeMinimalModel([
68
- { id: 'sales', label: 'Sales', enabled: true },
69
- makeFeature('sales.crm', '/sales/lead-gen'),
70
- { id: 'sales.lead-gen', label: 'Lead Gen', enabled: true }
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
+ })
253
+
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
+ })
71
270
  ])
72
271
  )
272
+ })
273
+
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
+ })
73
299
 
74
- expect(
75
- messages.some((message) =>
76
- message.includes('Feature "sales.lead-gen" effective path "/sales/lead-gen" duplicates feature "sales.crm"')
77
- )
78
- ).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
+ ])
79
314
  })
80
315
 
81
- it('rejects a dotted child when its immediate parent is missing', () => {
82
- const messages = getIssueMessages(makeMinimalModel([makeFeature('sales.crm.pipeline')]))
316
+ it('projects legacy entities, actions, and system content into compiled ontology indexes', () => {
317
+ const model = OrganizationModelSchema.parse(
318
+ makeMinimalModel({
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
+ }
329
+ }
330
+ })
331
+ )
83
332
 
84
- expect(messages.some((message) => message.includes('unknown parent "sales.crm"'))).toBe(true)
333
+ const compilation = compileOrganizationOntology(model)
334
+
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')
85
339
  })
86
340
 
87
- it('allows a leaf without a path so resolvers can derive a default path', () => {
88
- const result = OrganizationModelSchema.safeParse(makeMinimalModel([{ id: 'sales', label: 'Sales', enabled: true }]))
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
+ })
89
367
 
90
- expect(result.success).toBe(true)
368
+ const compilation = compileOrganizationOntology(model)
369
+
370
+ expect(compilation.ontology.objectTypes['dashboard:object/task']?.origin).toMatchObject({
371
+ kind: 'authored',
372
+ source: 'system:dashboard.ontology',
373
+ systemPath: 'dashboard'
374
+ })
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')
91
383
  })
92
384
 
93
- it('rejects an enabled container with no enabled descendants', () => {
94
- const messages = getIssueMessages(
95
- makeMinimalModel([
96
- { id: 'sales', label: 'Sales', enabled: true },
97
- { id: 'sales.crm', label: 'CRM', enabled: false, path: '/sales/crm/pipeline' }
98
- ])
99
- )
385
+ it('rejects duplicate ontology ids with source context', () => {
386
+ const messages = getIssueMessages({
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
+ }
398
+ }
399
+ })
100
400
 
101
- expect(messages.some((message) => message.includes('has no enabled descendants'))).toBe(true)
401
+ expect(messages.some((message) => message.includes('Duplicate ontology ID "dashboard:object/crm.deal"'))).toBe(
402
+ true
403
+ )
404
+ expect(messages.some((message) => message.includes('legacy.entities'))).toBe(true)
102
405
  })
103
406
 
104
- it('keeps legacy navigation inert during the release train', () => {
407
+ it('keeps duplicate diagnostics origin-aware for authored versus projected records', () => {
105
408
  const model = OrganizationModelSchema.parse(
106
- makeMinimalModel([makeFeature('dashboard', '/')])
409
+ makeMinimalModel({
410
+ dashboard: makeSystem('dashboard', '/')
411
+ })
107
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
+ }
423
+ }
424
+ })
108
425
 
109
- expect(model.navigation.surfaces).toEqual([])
110
- expect(model.navigation.groups).toEqual([])
111
- expect('resourceMappings' in model).toBe(false)
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
+ })
112
431
  })
113
432
 
114
- it('rejects an unknown navigation default surface when surfaces are explicitly empty', () => {
115
- const messages = getIssueMessages({
116
- ...makeMinimalModel([makeFeature('dashboard', '/')]),
117
- navigation: {
118
- defaultSurfaceId: 'missing.surface',
119
- surfaces: [],
120
- groups: []
433
+ it('accepts resource ontology bindings when referenced ontology records exist', () => {
434
+ const result = OrganizationModelSchema.safeParse({
435
+ ...makeMinimalModel({
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
+ }
470
+ }
471
+ }),
472
+ resources: {
473
+ 'task-processor': {
474
+ id: 'task-processor',
475
+ order: 10,
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
+ }
486
+ }
121
487
  }
122
488
  })
123
489
 
124
- expect(
125
- messages.some((message) =>
126
- message.includes('Navigation defaultSurfaceId references unknown surface "missing.surface"')
127
- )
128
- ).toBe(true)
490
+ expect(result.success).toBe(true)
129
491
  })
130
492
 
131
- it('rejects navigation group surfaceIds that reference missing surfaces', () => {
132
- const messages = getIssueMessages({
133
- ...makeMinimalModel([makeFeature('dashboard', '/')]),
134
- navigation: {
135
- surfaces: [makeSurface('dashboard.home', 'dashboard')],
136
- groups: [
137
- {
138
- id: 'primary',
139
- label: 'Primary',
140
- placement: 'primary',
141
- surfaceIds: ['missing.surface']
142
- }
143
- ]
144
- }
493
+ it('accepts known ontology references inside loose property, input, effect, entry, and payload fields', () => {
494
+ const result = OrganizationModelSchema.safeParse({
495
+ ...makeMinimalModel({
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
+ }
549
+ }
550
+ })
145
551
  })
146
552
 
147
- expect(
148
- messages.some((message) =>
149
- message.includes('Navigation group "primary" references unknown surface "missing.surface"')
150
- )
151
- ).toBe(true)
553
+ expect(result.success).toBe(true)
152
554
  })
153
555
 
154
- it('rejects navigation surface featureId references to missing features', () => {
556
+ it('rejects known ontology reference keys that point at missing records', () => {
155
557
  const messages = getIssueMessages({
156
- ...makeMinimalModel([makeFeature('dashboard', '/')]),
157
- navigation: {
158
- surfaces: [makeSurface('dashboard.home', 'missing.feature')],
159
- groups: []
160
- }
558
+ ...makeMinimalModel({
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
+ })
161
584
  })
162
585
 
163
- expect(
164
- messages.some((message) =>
165
- message.includes('Navigation surface "dashboard.home" references unknown feature "missing.feature"')
166
- )
167
- ).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)
168
593
  })
169
594
 
170
- it('rejects navigation surface featureIds references to missing features', () => {
595
+ it('rejects resource ontology bindings to missing compiled ontology records', () => {
171
596
  const messages = getIssueMessages({
172
- ...makeMinimalModel([makeFeature('dashboard', '/')]),
173
- navigation: {
174
- surfaces: [makeSurface('dashboard.home', 'dashboard', ['dashboard', 'missing.feature'])],
175
- groups: []
597
+ ...makeMinimalModel({
598
+ dashboard: makeSystem('dashboard', '/')
599
+ }),
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
+ }
176
614
  }
177
615
  })
178
616
 
179
- expect(
180
- messages.some((message) =>
181
- message.includes('Navigation surface "dashboard.home" references unknown feature "missing.feature"')
182
- )
183
- ).toBe(true)
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)
184
621
  })
622
+ })
185
623
 
186
- it('allows legacy navigation feature aliases when canonical features exist', () => {
187
- const result = OrganizationModelSchema.safeParse({
188
- ...makeMinimalModel([
189
- { id: 'sales', label: 'Sales', enabled: true },
190
- makeFeature('sales.crm', '/sales/crm/pipeline'),
191
- makeFeature('sales.lead-gen', '/lead-gen/lists')
192
- ]),
193
- navigation: {
194
- defaultSurfaceId: 'crm.pipeline',
195
- surfaces: [
196
- makeSurface('crm.pipeline', 'crm', ['crm']),
197
- makeSurface('lead-gen.lists', 'lead-gen', ['lead-gen'])
198
- ],
199
- groups: []
200
- }
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]
201
649
  })
650
+ })
202
651
 
203
- 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)
665
+ })
666
+
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 }
693
+ })
694
+
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 })
204
712
  })
205
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
+ })