@elevasis/core 0.23.0 → 0.24.1

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 (243) hide show
  1. package/dist/index.d.ts +4343 -2690
  2. package/dist/index.js +1101 -156
  3. package/dist/knowledge/index.d.ts +574 -210
  4. package/dist/knowledge/index.js +104 -1
  5. package/dist/organization-model/index.d.ts +4343 -2690
  6. package/dist/organization-model/index.js +1101 -156
  7. package/dist/test-utils/index.d.ts +483 -109
  8. package/dist/test-utils/index.js +904 -144
  9. package/package.json +3 -3
  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 +2137 -2093
  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 +195 -197
  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 +387 -277
  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__/domains/topology.test.ts +188 -0
  147. package/src/organization-model/__tests__/flatten-additive-merge.test.ts +362 -361
  148. package/src/organization-model/__tests__/foundation.test.ts +77 -77
  149. package/src/organization-model/__tests__/get-resources-for-system.test.ts +144 -144
  150. package/src/organization-model/__tests__/graph.test.ts +1312 -862
  151. package/src/organization-model/__tests__/icons.test.ts +10 -1
  152. package/src/organization-model/__tests__/knowledge.test.ts +251 -15
  153. package/src/organization-model/__tests__/lookup-helpers.test.ts +438 -438
  154. package/src/organization-model/__tests__/migration-helpers.test.ts +591 -591
  155. package/src/organization-model/__tests__/prospecting-ssot.test.ts +103 -103
  156. package/src/organization-model/__tests__/recursive-system-schema.test.ts +535 -506
  157. package/src/organization-model/__tests__/resolve.test.ts +274 -164
  158. package/src/organization-model/__tests__/schema.test.ts +844 -301
  159. package/src/organization-model/__tests__/surface-projection.test.ts +284 -284
  160. package/src/organization-model/catalogs/lead-gen.ts +144 -144
  161. package/src/organization-model/content-kinds/config.ts +36 -36
  162. package/src/organization-model/content-kinds/index.ts +76 -72
  163. package/src/organization-model/content-kinds/pipeline.ts +68 -68
  164. package/src/organization-model/content-kinds/registry.ts +44 -44
  165. package/src/organization-model/content-kinds/status.ts +71 -71
  166. package/src/organization-model/content-kinds/template.ts +83 -83
  167. package/src/organization-model/content-kinds/types.ts +117 -117
  168. package/src/organization-model/contracts.ts +27 -27
  169. package/src/organization-model/defaults.ts +42 -50
  170. package/src/organization-model/domains/actions.ts +333 -239
  171. package/src/organization-model/domains/customers.ts +78 -78
  172. package/src/organization-model/domains/entities.ts +144 -144
  173. package/src/organization-model/domains/goals.ts +83 -83
  174. package/src/organization-model/domains/knowledge.ts +117 -101
  175. package/src/organization-model/domains/navigation.ts +139 -139
  176. package/src/organization-model/domains/offerings.ts +71 -71
  177. package/src/organization-model/domains/policies.ts +102 -102
  178. package/src/organization-model/domains/projects.ts +14 -14
  179. package/src/organization-model/domains/prospecting.ts +395 -395
  180. package/src/organization-model/domains/resources.ts +202 -124
  181. package/src/organization-model/domains/roles.ts +96 -96
  182. package/src/organization-model/domains/sales.test.ts +218 -218
  183. package/src/organization-model/domains/sales.ts +380 -380
  184. package/src/organization-model/domains/shared.ts +63 -63
  185. package/src/organization-model/domains/statuses.ts +339 -339
  186. package/src/organization-model/domains/systems.ts +217 -172
  187. package/src/organization-model/domains/topology.ts +261 -0
  188. package/src/organization-model/foundation.ts +75 -75
  189. package/src/organization-model/graph/build.ts +1043 -867
  190. package/src/organization-model/graph/index.ts +4 -4
  191. package/src/organization-model/graph/link.ts +10 -10
  192. package/src/organization-model/graph/schema.ts +75 -68
  193. package/src/organization-model/graph/types.ts +71 -64
  194. package/src/organization-model/helpers.ts +289 -241
  195. package/src/organization-model/icons.ts +78 -66
  196. package/src/organization-model/index.ts +128 -125
  197. package/src/organization-model/migration-helpers.ts +247 -244
  198. package/src/organization-model/ontology.ts +658 -0
  199. package/src/organization-model/organization-graph.mdx +110 -90
  200. package/src/organization-model/organization-model.mdx +225 -213
  201. package/src/organization-model/published.ts +299 -222
  202. package/src/organization-model/resolve.ts +146 -91
  203. package/src/organization-model/schema.ts +818 -659
  204. package/src/organization-model/surface-projection.ts +212 -212
  205. package/src/organization-model/types.ts +179 -155
  206. package/src/platform/api/types.ts +38 -38
  207. package/src/platform/constants/versions.ts +3 -3
  208. package/src/platform/index.ts +23 -23
  209. package/src/platform/registry/__tests__/command-view.test.ts +10 -10
  210. package/src/platform/registry/__tests__/resource-link.test.ts +35 -35
  211. package/src/platform/registry/__tests__/resource-registry.integration.test.ts +20 -20
  212. package/src/platform/registry/__tests__/resource-registry.nested-systems.test.ts +245 -245
  213. package/src/platform/registry/__tests__/resource-registry.test.ts +2053 -2053
  214. package/src/platform/registry/__tests__/validation.test.ts +1444 -1259
  215. package/src/platform/registry/command-view.ts +10 -10
  216. package/src/platform/registry/index.ts +103 -103
  217. package/src/platform/registry/resource-link.ts +32 -32
  218. package/src/platform/registry/resource-registry.ts +886 -886
  219. package/src/platform/registry/serialization.ts +295 -295
  220. package/src/platform/registry/serialized-types.ts +166 -166
  221. package/src/platform/registry/stats-types.ts +68 -68
  222. package/src/platform/registry/types.ts +425 -425
  223. package/src/platform/registry/validation.ts +876 -684
  224. package/src/platform/utils/__tests__/validation.test.ts +1084 -1084
  225. package/src/platform/utils/validation.ts +425 -425
  226. package/src/projects/api-schemas.test.ts +39 -39
  227. package/src/projects/api-schemas.ts +291 -291
  228. package/src/reference/_generated/contracts.md +2136 -2093
  229. package/src/reference/glossary.md +76 -76
  230. package/src/scaffold-registry/__tests__/index.test.ts +206 -206
  231. package/src/scaffold-registry/__tests__/schema.test.ts +166 -166
  232. package/src/scaffold-registry/index.ts +392 -392
  233. package/src/scaffold-registry/schema.ts +243 -243
  234. package/src/server.ts +289 -289
  235. package/src/supabase/database.types.ts +3 -0
  236. package/src/test-utils/README.md +37 -37
  237. package/src/test-utils/entities.ts +108 -108
  238. package/src/test-utils/fixtures/memberships.ts +82 -82
  239. package/src/test-utils/index.ts +12 -12
  240. package/src/test-utils/organization-model.ts +65 -65
  241. package/src/test-utils/published.ts +6 -6
  242. package/src/test-utils/rls/RLSTestContext.ts +588 -588
  243. package/src/test-utils/test-utils.test.ts +44 -44
@@ -1,266 +1,497 @@
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
- })
120
-
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.
143
- })
333
+ const compilation = compileOrganizationOntology(model)
144
334
 
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.
335
+ expect(compilation.diagnostics).toEqual([])
336
+ expect(compilation.ontology.objectTypes['dashboard:object/crm.deal']).not.toHaveProperty('legacyEntityId')
337
+ expect(compilation.ontology.objectTypes['dashboard:object/crm.deal']?.origin).toMatchObject({
338
+ source: 'legacy.entities',
339
+ legacyId: 'crm.deal'
340
+ })
341
+ expect(compilation.ontology.actionTypes['dashboard:action/send_reply']?.legacyActionId).toBe('send_reply')
342
+ expect(compilation.ontology.catalogTypes['dashboard:catalog/pipeline']).not.toHaveProperty('legacyContentId')
343
+ expect(compilation.ontology.catalogTypes['dashboard:catalog/pipeline']?.origin).toMatchObject({
344
+ source: 'legacy.system.content',
345
+ legacyId: 'dashboard:pipeline'
346
+ })
148
347
  })
149
348
 
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
- })
349
+ it('adds origin metadata to authored and projected compiled ontology records without mutating source', () => {
350
+ const authoredObject = {
351
+ id: 'dashboard:object/task',
352
+ label: 'Task',
353
+ ownerSystemId: 'dashboard'
354
+ }
355
+ const model = OrganizationModelSchema.parse({
356
+ ...makeMinimalModel({
357
+ dashboard: {
358
+ ...makeSystem('dashboard', '/'),
359
+ ontology: {
360
+ objectTypes: {
361
+ 'dashboard:object/task': authoredObject
362
+ }
363
+ },
364
+ content: {
365
+ pipeline: {
366
+ kind: 'schema',
367
+ type: 'pipeline',
368
+ label: 'Pipeline',
369
+ data: { entityId: 'crm.deal' }
370
+ }
371
+ }
372
+ }
373
+ })
374
+ })
155
375
 
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
- })
376
+ const compilation = compileOrganizationOntology(model)
169
377
 
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
- }
378
+ expect(compilation.ontology.objectTypes['dashboard:object/task']?.origin).toMatchObject({
379
+ kind: 'authored',
380
+ source: 'system:dashboard.ontology',
381
+ systemPath: 'dashboard'
176
382
  })
177
-
178
- expect(messages.some((message) => message.includes('lastModified must be an ISO date string'))).toBe(true)
383
+ expect(compilation.ontology.catalogTypes['dashboard:catalog/pipeline']?.origin).toMatchObject({
384
+ kind: 'projected',
385
+ source: 'legacy.system.content',
386
+ systemPath: 'dashboard',
387
+ legacyId: 'dashboard:pipeline'
388
+ })
389
+ expect(authoredObject).not.toHaveProperty('origin')
390
+ expect(model.systems.dashboard?.ontology?.objectTypes?.['dashboard:object/task']).not.toHaveProperty('origin')
179
391
  })
180
- })
181
392
 
182
- describe('entity validation', () => {
183
- it('rejects entities owned by unknown systems', () => {
393
+ it('rejects duplicate ontology ids with source context', () => {
184
394
  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' }
395
+ ...makeMinimalModel({
396
+ dashboard: makeSystem('dashboard', '/')
397
+ }),
398
+ ontology: {
399
+ objectTypes: {
400
+ 'dashboard:object/crm.deal': {
401
+ id: 'dashboard:object/crm.deal',
402
+ label: 'Duplicate Deal',
403
+ ownerSystemId: 'dashboard'
404
+ }
405
+ }
194
406
  }
195
407
  })
196
408
 
197
- expect(messages.some((message) => message.includes('references unknown ownedBySystemId "missing.system"'))).toBe(
409
+ expect(messages.some((message) => message.includes('Duplicate ontology ID "dashboard:object/crm.deal"'))).toBe(
198
410
  true
199
411
  )
412
+ expect(messages.some((message) => message.includes('legacy.entities'))).toBe(true)
200
413
  })
201
414
 
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' }
415
+ it('keeps duplicate diagnostics origin-aware for authored versus projected records', () => {
416
+ const model = OrganizationModelSchema.parse(
417
+ makeMinimalModel({
418
+ dashboard: makeSystem('dashboard', '/')
419
+ })
420
+ )
421
+ const compilation = compileOrganizationOntology({
422
+ ...model,
423
+ ontology: {
424
+ objectTypes: {
425
+ 'dashboard:object/crm.deal': {
426
+ id: 'dashboard:object/crm.deal',
427
+ label: 'Duplicate Deal',
428
+ ownerSystemId: 'dashboard'
429
+ }
430
+ }
219
431
  }
220
432
  })
221
433
 
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.
434
+ expect(compilation.diagnostics[0]).toMatchObject({
435
+ code: 'duplicate_ontology_id',
436
+ origin: { kind: 'projected', source: 'legacy.entities', legacyId: 'crm.deal' },
437
+ existingOrigin: { kind: 'authored', source: 'organization.ontology' }
438
+ })
230
439
  })
231
- })
232
440
 
233
- describe('system action and policy validation', () => {
234
- it('accepts systems that attach known actions and policies', () => {
441
+ it('accepts resource ontology bindings when referenced ontology records exist', () => {
235
442
  const result = OrganizationModelSchema.safeParse({
236
443
  ...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']
444
+ dashboard: {
445
+ ...makeSystem('dashboard', '/'),
446
+ ontology: {
447
+ objectTypes: {
448
+ 'dashboard:object/task': {
449
+ id: 'dashboard:object/task',
450
+ label: 'Task',
451
+ ownerSystemId: 'dashboard'
452
+ }
453
+ },
454
+ actionTypes: {
455
+ 'dashboard:action/process-task': {
456
+ id: 'dashboard:action/process-task',
457
+ label: 'Process Task',
458
+ ownerSystemId: 'dashboard',
459
+ actsOn: ['dashboard:object/task']
460
+ }
461
+ },
462
+ catalogTypes: {
463
+ 'dashboard:catalog/task-status': {
464
+ id: 'dashboard:catalog/task-status',
465
+ label: 'Task Status',
466
+ ownerSystemId: 'dashboard',
467
+ appliesTo: 'dashboard:object/task'
468
+ }
469
+ },
470
+ eventTypes: {
471
+ 'dashboard:event/task-processed': {
472
+ id: 'dashboard:event/task-processed',
473
+ label: 'Task Processed',
474
+ ownerSystemId: 'dashboard'
475
+ }
476
+ }
477
+ }
254
478
  }
255
- },
256
- policies: {
257
- 'policy.lead-gen.approval': {
258
- id: 'policy.lead-gen.approval',
479
+ }),
480
+ resources: {
481
+ 'task-processor': {
482
+ id: 'task-processor',
259
483
  order: 10,
260
- label: 'Lead Gen Approval',
261
- trigger: { kind: 'manual' },
262
- actions: [{ kind: 'block' }],
263
- appliesTo: { systemIds: ['sales.lead-gen'] }
484
+ kind: 'workflow',
485
+ systemPath: 'dashboard',
486
+ status: 'active',
487
+ ontology: {
488
+ actions: ['dashboard:action/process-task'],
489
+ primaryAction: 'dashboard:action/process-task',
490
+ reads: ['dashboard:object/task'],
491
+ writes: ['dashboard:object/task'],
492
+ usesCatalogs: ['dashboard:catalog/task-status'],
493
+ emits: ['dashboard:event/task-processed']
494
+ }
264
495
  }
265
496
  }
266
497
  })
@@ -268,115 +499,427 @@ describe('system action and policy validation', () => {
268
499
  expect(result.success).toBe(true)
269
500
  })
270
501
 
271
- it('rejects systems that attach unknown actions or policies', () => {
272
- const messages = getIssueMessages({
502
+ it('accepts known ontology references inside loose property, input, effect, entry, and payload fields', () => {
503
+ const result = OrganizationModelSchema.safeParse({
273
504
  ...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']
505
+ dashboard: {
506
+ ...makeSystem('dashboard', '/'),
507
+ ontology: {
508
+ objectTypes: {
509
+ 'dashboard:object/task': {
510
+ id: 'dashboard:object/task',
511
+ label: 'Task',
512
+ ownerSystemId: 'dashboard',
513
+ properties: {
514
+ id: { valueType: 'global:value-type/uuid' },
515
+ status: { catalogType: 'dashboard:catalog/task-status' }
516
+ }
517
+ }
518
+ },
519
+ actionTypes: {
520
+ 'dashboard:action/process-task': {
521
+ id: 'dashboard:action/process-task',
522
+ label: 'Process Task',
523
+ ownerSystemId: 'dashboard',
524
+ actsOn: ['dashboard:object/task'],
525
+ input: {
526
+ status: { catalogType: 'dashboard:catalog/task-status' }
527
+ },
528
+ effects: [
529
+ { kind: 'write', objectType: 'dashboard:object/task' },
530
+ { kind: 'emitEvent', eventType: 'dashboard:event/task-processed' }
531
+ ]
532
+ }
533
+ },
534
+ catalogTypes: {
535
+ 'dashboard:catalog/task-status': {
536
+ id: 'dashboard:catalog/task-status',
537
+ label: 'Task Status',
538
+ ownerSystemId: 'dashboard',
539
+ appliesTo: 'dashboard:object/task',
540
+ entries: {
541
+ todo: { label: 'To Do' },
542
+ done: { label: 'Done', stepCatalog: 'dashboard:catalog/task-status' }
543
+ }
544
+ }
545
+ },
546
+ eventTypes: {
547
+ 'dashboard:event/task-processed': {
548
+ id: 'dashboard:event/task-processed',
549
+ label: 'Task Processed',
550
+ ownerSystemId: 'dashboard',
551
+ payload: {
552
+ taskId: { valueType: 'global:value-type/uuid' },
553
+ status: { catalogType: 'dashboard:catalog/task-status' }
554
+ }
555
+ }
556
+ }
557
+ }
291
558
  }
292
- }
559
+ })
293
560
  })
294
561
 
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)
562
+ expect(result.success).toBe(true)
297
563
  })
298
- })
299
564
 
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({
565
+ it('rejects known ontology reference keys that point at missing records', () => {
566
+ const messages = getIssueMessages({
319
567
  ...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
- }
568
+ dashboard: {
569
+ ...makeSystem('dashboard', '/'),
570
+ ontology: {
571
+ objectTypes: {
572
+ 'dashboard:object/task': {
573
+ id: 'dashboard:object/task',
574
+ label: 'Task',
575
+ ownerSystemId: 'dashboard',
576
+ properties: {
577
+ badValue: { valueType: 'global:value-type/missing' },
578
+ badCatalog: { catalogType: 'dashboard:catalog/missing' }
579
+ }
580
+ }
581
+ },
582
+ actionTypes: {
583
+ 'dashboard:action/process-task': {
584
+ id: 'dashboard:action/process-task',
585
+ label: 'Process Task',
586
+ ownerSystemId: 'dashboard',
587
+ effects: [{ kind: 'emitEvent', eventType: 'dashboard:event/missing' }]
588
+ }
589
+ }
590
+ }
591
+ }
592
+ })
326
593
  })
327
594
 
328
- expect(result.success).toBe(true)
595
+ expect(messages.some((message) => message.includes('valueType references unknown value-type ontology ID'))).toBe(
596
+ true
597
+ )
598
+ expect(messages.some((message) => message.includes('catalogType references unknown catalog ontology ID'))).toBe(
599
+ true
600
+ )
601
+ expect(messages.some((message) => message.includes('eventType references unknown event ontology ID'))).toBe(true)
329
602
  })
330
603
 
331
- it('rejects typed knowledge links to unknown modeled targets', () => {
604
+ it('rejects resource ontology bindings to missing compiled ontology records', () => {
332
605
  const messages = getIssueMessages({
333
606
  ...makeMinimalModel({
334
- sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
335
- 'sales.crm': makeSystem('sales.crm', '/crm')
607
+ dashboard: makeSystem('dashboard', '/')
336
608
  }),
337
- knowledge: {
338
- 'knowledge.sales-crm-playbook': makeKnowledgeNode({
339
- links: [{ target: { kind: 'resource', id: 'missing.workflow' } }]
340
- })
609
+ resources: {
610
+ 'task-processor': {
611
+ id: 'task-processor',
612
+ order: 10,
613
+ kind: 'workflow',
614
+ systemPath: 'dashboard',
615
+ status: 'active',
616
+ ontology: {
617
+ actions: ['dashboard:action/missing-task'],
618
+ primaryAction: 'dashboard:action/missing-task',
619
+ reads: ['dashboard:object/missing-task'],
620
+ usesCatalogs: ['dashboard:catalog/missing-status'],
621
+ emits: ['dashboard:event/missing-event']
622
+ }
623
+ }
341
624
  }
342
625
  })
343
626
 
344
- expect(messages.some((message) => message.includes('references unknown resource target "missing.workflow"'))).toBe(
345
- true
346
- )
627
+ expect(messages.some((message) => message.includes('references unknown action ontology ID'))).toBe(true)
628
+ expect(messages.some((message) => message.includes('references unknown object ontology ID'))).toBe(true)
629
+ expect(messages.some((message) => message.includes('references unknown catalog ontology ID'))).toBe(true)
630
+ expect(messages.some((message) => message.includes('references unknown event ontology ID'))).toBe(true)
347
631
  })
632
+ })
348
633
 
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
- }
634
+ describe('system config contract', () => {
635
+ it('accepts authored system-local JSON config', () => {
636
+ const model = OrganizationModelSchema.parse(
637
+ makeMinimalModel({
638
+ dashboard: {
639
+ ...makeSystem('dashboard', '/'),
640
+ config: {
641
+ defaultPipelineId: 'sales.crm:catalog/pipeline',
642
+ kanban: {
643
+ groupBy: 'stage',
644
+ showProbability: true
645
+ },
646
+ retryDelays: [100, 500, null]
647
+ }
648
+ }
649
+ })
650
+ )
651
+
652
+ expect(model.systems.dashboard?.config).toEqual({
653
+ defaultPipelineId: 'sales.crm:catalog/pipeline',
654
+ kanban: {
655
+ groupBy: 'stage',
656
+ showProbability: true
657
+ },
658
+ retryDelays: [100, 500, null]
361
659
  })
660
+ })
362
661
 
363
- expect(result.success).toBe(true)
662
+ it('rejects non-JSON system config values', () => {
663
+ const result = OrganizationModelSchema.safeParse(
664
+ makeMinimalModel({
665
+ dashboard: {
666
+ ...makeSystem('dashboard', '/'),
667
+ config: {
668
+ callback: () => true
669
+ }
670
+ }
671
+ })
672
+ )
673
+
674
+ expect(result.success).toBe(false)
364
675
  })
365
676
 
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
- }
677
+ it('projects bridge-era config:kv into effective resolved system config', () => {
678
+ const model = OrganizationModelSchema.parse(
679
+ makeMinimalModel({
680
+ dashboard: {
681
+ ...makeSystem('dashboard', '/'),
682
+ config: {
683
+ retries: 3,
684
+ nested: { direct: true }
685
+ },
686
+ content: {
687
+ settings: {
688
+ kind: 'config',
689
+ type: 'kv',
690
+ label: 'Settings',
691
+ data: { entries: { enabled: true, retries: 1, mode: 'bridge' } }
692
+ }
693
+ }
694
+ }
695
+ })
696
+ )
697
+
698
+ expect(resolveSystemConfig(model, 'dashboard')).toEqual({
699
+ enabled: true,
700
+ retries: 3,
701
+ mode: 'bridge',
702
+ nested: { direct: true }
378
703
  })
379
704
 
380
- expect(messages.some((message) => message.includes('kind "playbook" cannot govern goal targets'))).toBe(true)
705
+ const resolved = resolveOrganizationModelWithResources({
706
+ systems: {
707
+ dashboard: {
708
+ ...makeSystem('dashboard', '/'),
709
+ config: { retries: 3 },
710
+ content: {
711
+ settings: {
712
+ kind: 'config',
713
+ type: 'kv',
714
+ label: 'Settings',
715
+ data: { entries: { enabled: true, retries: 1 } }
716
+ }
717
+ }
718
+ }
719
+ }
720
+ })
721
+ expect(resolved.systems.dashboard?.config).toEqual({ enabled: true, retries: 3 })
381
722
  })
382
723
  })
724
+
725
+ describe('entity validation', () => {
726
+ it('rejects entities owned by unknown systems', () => {
727
+ const messages = getIssueMessages({
728
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
729
+ entities: {
730
+ 'crm.deal': { id: 'crm.deal', order: 10, label: 'Deal', ownedBySystemId: 'missing.system' },
731
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
732
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
733
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
734
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
735
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
736
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
737
+ }
738
+ })
739
+
740
+ expect(messages.some((message) => message.includes('references unknown ownedBySystemId "missing.system"'))).toBe(
741
+ true
742
+ )
743
+ })
744
+
745
+ it('rejects entity links to unknown entities', () => {
746
+ const messages = getIssueMessages({
747
+ ...makeMinimalModel({ dashboard: makeSystem('dashboard', '/') }),
748
+ entities: {
749
+ 'crm.deal': {
750
+ id: 'crm.deal',
751
+ order: 10,
752
+ label: 'Deal',
753
+ ownedBySystemId: 'dashboard',
754
+ links: [{ toEntity: 'missing.entity', kind: 'belongs-to' }]
755
+ },
756
+ 'leadgen.list': { id: 'leadgen.list', order: 20, label: 'Lead List', ownedBySystemId: 'dashboard' },
757
+ 'leadgen.company': { id: 'leadgen.company', order: 30, label: 'Lead Company', ownedBySystemId: 'dashboard' },
758
+ 'leadgen.contact': { id: 'leadgen.contact', order: 40, label: 'Lead Contact', ownedBySystemId: 'dashboard' },
759
+ 'delivery.project': { id: 'delivery.project', order: 50, label: 'Project', ownedBySystemId: 'dashboard' },
760
+ 'delivery.milestone': { id: 'delivery.milestone', order: 60, label: 'Milestone', ownedBySystemId: 'dashboard' },
761
+ 'delivery.task': { id: 'delivery.task', order: 70, label: 'Task', ownedBySystemId: 'dashboard' }
762
+ }
763
+ })
764
+
765
+ expect(messages.some((message) => message.includes('links to unknown entity "missing.entity"'))).toBe(true)
766
+ })
767
+
768
+ // Phase 4: Prospecting.contactEntityId validation removed (prospecting domain deleted).
769
+ // The separate test for prospecting entity refs is skipped below.
770
+ it.skip('rejects prospecting contactEntityId referencing unknown entity (deferred — Phase 4: prospecting domain removed)', () => {
771
+ // Previously tested: model.prospecting.contactEntityId referencing a missing entity.
772
+ // The prospecting domain no longer exists at the top-level OM schema level.
773
+ })
774
+ })
775
+
776
+ describe('system action and policy validation', () => {
777
+ it('accepts systems that attach known actions and policies', () => {
778
+ const result = OrganizationModelSchema.safeParse({
779
+ ...makeMinimalModel({
780
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
781
+ 'sales.lead-gen': makeSystem('sales.lead-gen')
782
+ }),
783
+ systems: {
784
+ sales: {
785
+ id: 'sales',
786
+ order: 10,
787
+ label: 'Sales',
788
+ lifecycle: 'active'
789
+ },
790
+ 'sales.lead-gen': {
791
+ id: 'sales.lead-gen',
792
+ order: 20,
793
+ label: 'Lead Gen',
794
+ lifecycle: 'active',
795
+ actions: [{ actionId: 'lead-gen.company.source', intent: 'exposes' }],
796
+ policies: ['policy.lead-gen.approval']
797
+ }
798
+ },
799
+ policies: {
800
+ 'policy.lead-gen.approval': {
801
+ id: 'policy.lead-gen.approval',
802
+ order: 10,
803
+ label: 'Lead Gen Approval',
804
+ trigger: { kind: 'manual' },
805
+ actions: [{ kind: 'block' }],
806
+ appliesTo: { systemIds: ['sales.lead-gen'] }
807
+ }
808
+ }
809
+ })
810
+
811
+ expect(result.success).toBe(true)
812
+ })
813
+
814
+ it('rejects systems that attach unknown actions or policies', () => {
815
+ const messages = getIssueMessages({
816
+ ...makeMinimalModel({
817
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
818
+ 'sales.lead-gen': makeSystem('sales.lead-gen')
819
+ }),
820
+ systems: {
821
+ sales: {
822
+ id: 'sales',
823
+ order: 10,
824
+ label: 'Sales',
825
+ lifecycle: 'active'
826
+ },
827
+ 'sales.lead-gen': {
828
+ id: 'sales.lead-gen',
829
+ order: 20,
830
+ label: 'Lead Gen',
831
+ lifecycle: 'active',
832
+ actions: [{ actionId: 'missing.action', intent: 'exposes' }],
833
+ policies: ['missing.policy']
834
+ }
835
+ }
836
+ })
837
+
838
+ expect(messages.some((message) => message.includes('references unknown action "missing.action"'))).toBe(true)
839
+ expect(messages.some((message) => message.includes('references unknown policy "missing.policy"'))).toBe(true)
840
+ })
841
+ })
842
+
843
+ describe('knowledge governs validation', () => {
844
+ function makeKnowledgeNode(overrides: Record<string, unknown> = {}) {
845
+ return {
846
+ id: 'knowledge.sales-crm-playbook',
847
+ kind: 'playbook' as const,
848
+ title: 'Sales CRM Playbook',
849
+ summary: 'How CRM work is governed.',
850
+ body: '## CRM',
851
+ updatedAt: '2026-05-10',
852
+ links: [{ target: { kind: 'system', id: 'sales.crm' } }],
853
+ ...overrides
854
+ }
855
+ }
856
+
857
+ // Phase 4 (D3): knowledge is now a flat Record<id, OrgKnowledgeNode>.
858
+ // Fixtures updated from { nodes: [...] } to { 'knowledge.id': {...node} }.
859
+
860
+ it('accepts typed knowledge links when the target exists', () => {
861
+ const result = OrganizationModelSchema.safeParse({
862
+ ...makeMinimalModel({
863
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
864
+ 'sales.crm': makeSystem('sales.crm', '/crm')
865
+ }),
866
+ knowledge: {
867
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode()
868
+ }
869
+ })
870
+
871
+ expect(result.success).toBe(true)
872
+ })
873
+
874
+ it('rejects typed knowledge links to unknown modeled targets', () => {
875
+ const messages = getIssueMessages({
876
+ ...makeMinimalModel({
877
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
878
+ 'sales.crm': makeSystem('sales.crm', '/crm')
879
+ }),
880
+ knowledge: {
881
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
882
+ links: [{ target: { kind: 'resource', id: 'missing.workflow' } }]
883
+ })
884
+ }
885
+ })
886
+
887
+ expect(messages.some((message) => message.includes('references unknown resource target "missing.workflow"'))).toBe(
888
+ true
889
+ )
890
+ })
891
+
892
+ it('allows strategy nodes to target systems', () => {
893
+ const result = OrganizationModelSchema.safeParse({
894
+ ...makeMinimalModel({
895
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
896
+ 'sales.crm': makeSystem('sales.crm', '/crm')
897
+ }),
898
+ knowledge: {
899
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
900
+ kind: 'strategy',
901
+ links: [{ target: { kind: 'system', id: 'sales.crm' } }]
902
+ })
903
+ }
904
+ })
905
+
906
+ expect(result.success).toBe(true)
907
+ })
908
+
909
+ it('rejects incompatible knowledge kind to target kind pairings', () => {
910
+ const messages = getIssueMessages({
911
+ ...makeMinimalModel({
912
+ sales: { id: 'sales', order: 10, label: 'Sales', enabled: true, lifecycle: 'active' },
913
+ 'sales.crm': makeSystem('sales.crm', '/crm')
914
+ }),
915
+ knowledge: {
916
+ 'knowledge.sales-crm-playbook': makeKnowledgeNode({
917
+ kind: 'playbook',
918
+ links: [{ target: { kind: 'goal', id: 'missing.goal' } }]
919
+ })
920
+ }
921
+ })
922
+
923
+ expect(messages.some((message) => message.includes('kind "playbook" cannot govern goal targets'))).toBe(true)
924
+ })
925
+ })