@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.
- package/dist/index.d.ts +4343 -2690
- package/dist/index.js +1101 -156
- package/dist/knowledge/index.d.ts +574 -210
- package/dist/knowledge/index.js +104 -1
- package/dist/organization-model/index.d.ts +4343 -2690
- package/dist/organization-model/index.js +1101 -156
- package/dist/test-utils/index.d.ts +483 -109
- package/dist/test-utils/index.js +904 -144
- package/package.json +3 -3
- package/src/README.md +14 -14
- package/src/__tests__/publish.test.ts +24 -24
- package/src/__tests__/template-core-compatibility.test.ts +9 -12
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +2137 -2093
- package/src/_gen/__tests__/scaffold-contracts.test.ts +30 -30
- package/src/auth/multi-tenancy/credentials/__tests__/encryption.test.ts +217 -217
- package/src/auth/multi-tenancy/credentials/server/encryption.ts +69 -69
- package/src/auth/multi-tenancy/credentials/server/kek-loader.ts +37 -37
- package/src/auth/multi-tenancy/index.ts +26 -26
- package/src/auth/multi-tenancy/invitations/api-schemas.ts +104 -104
- package/src/auth/multi-tenancy/memberships/api-schemas.ts +143 -143
- package/src/auth/multi-tenancy/memberships/index.ts +26 -26
- package/src/auth/multi-tenancy/memberships/membership.ts +130 -130
- package/src/auth/multi-tenancy/organizations/__tests__/api-schemas.test.ts +194 -194
- package/src/auth/multi-tenancy/organizations/api-schemas.ts +136 -136
- package/src/auth/multi-tenancy/permissions.test.ts +42 -42
- package/src/auth/multi-tenancy/permissions.ts +123 -123
- package/src/auth/multi-tenancy/role-management/api-schemas.ts +78 -78
- package/src/auth/multi-tenancy/role-management/index.ts +16 -16
- package/src/auth/multi-tenancy/theme-presets.ts +45 -45
- package/src/auth/multi-tenancy/types.ts +57 -57
- package/src/auth/multi-tenancy/users/api-schemas.ts +165 -165
- package/src/business/README.md +2 -2
- package/src/business/acquisition/activity-events.test.ts +250 -250
- package/src/business/acquisition/activity-events.ts +93 -93
- package/src/business/acquisition/api-schemas.test.ts +1883 -1843
- package/src/business/acquisition/api-schemas.ts +1492 -1497
- package/src/business/acquisition/build-templates.test.ts +240 -240
- package/src/business/acquisition/build-templates.ts +98 -98
- package/src/business/acquisition/crm-next-action.test.ts +262 -262
- package/src/business/acquisition/crm-next-action.ts +220 -220
- package/src/business/acquisition/crm-priority.test.ts +216 -216
- package/src/business/acquisition/crm-priority.ts +349 -349
- package/src/business/acquisition/crm-state-actions.test.ts +153 -153
- package/src/business/acquisition/deal-ownership.test.ts +351 -351
- package/src/business/acquisition/deal-ownership.ts +120 -120
- package/src/business/acquisition/derive-actions.test.ts +129 -104
- package/src/business/acquisition/derive-actions.ts +74 -84
- package/src/business/acquisition/index.ts +171 -170
- package/src/business/acquisition/ontology-validation.ts +309 -0
- package/src/business/acquisition/stateful.ts +30 -30
- package/src/business/acquisition/types.ts +396 -396
- package/src/business/clients/api-schemas.test.ts +115 -115
- package/src/business/clients/api-schemas.ts +158 -158
- package/src/business/clients/index.ts +1 -1
- package/src/business/crm/api-schemas.ts +40 -40
- package/src/business/crm/index.ts +1 -1
- package/src/business/deals/api-schemas.ts +87 -87
- package/src/business/deals/index.ts +1 -1
- package/src/business/index.ts +5 -5
- package/src/business/projects/types.ts +144 -144
- package/src/commands/queue/types/task.ts +15 -15
- package/src/execution/core/runner-types.ts +61 -61
- package/src/execution/core/sse-executions.ts +7 -7
- package/src/execution/engine/__tests__/fixtures/test-agents.ts +10 -10
- package/src/execution/engine/agent/core/__tests__/agent.test.ts +16 -16
- package/src/execution/engine/agent/core/__tests__/error-passthrough.test.ts +4 -4
- package/src/execution/engine/agent/core/types.ts +25 -25
- package/src/execution/engine/agent/index.ts +6 -6
- package/src/execution/engine/agent/reasoning/__tests__/request-builder.test.ts +24 -24
- package/src/execution/engine/index.ts +443 -443
- package/src/execution/engine/tools/integration/server/adapters/apify/__tests__/apify-run-actor.integration.test.ts +298 -298
- package/src/execution/engine/tools/integration/server/adapters/apify/apify-adapter.test.ts +55 -55
- package/src/execution/engine/tools/integration/server/adapters/apify/apify-adapter.ts +107 -107
- package/src/execution/engine/tools/integration/server/adapters/apollo/apollo-adapter.test.ts +48 -48
- package/src/execution/engine/tools/integration/server/adapters/apollo/apollo-adapter.ts +99 -99
- package/src/execution/engine/tools/integration/server/adapters/apollo/index.ts +1 -1
- package/src/execution/engine/tools/integration/server/adapters/attio/__tests__/attio-crud.integration.test.ts +363 -363
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/get-record/index.test.ts +162 -162
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/list-records/index.test.ts +316 -316
- package/src/execution/engine/tools/integration/server/adapters/clickup/clickup-adapter.test.ts +18 -18
- package/src/execution/engine/tools/integration/server/adapters/clickup/clickup-adapter.ts +194 -194
- package/src/execution/engine/tools/integration/server/adapters/clickup/index.ts +7 -7
- package/src/execution/engine/tools/integration/server/adapters/gmail/gmail-adapter.ts +204 -204
- package/src/execution/engine/tools/integration/server/adapters/gmail/gmail-tools.ts +105 -105
- package/src/execution/engine/tools/integration/server/adapters/google-calendar/google-calendar-adapter.ts +428 -428
- package/src/execution/engine/tools/integration/server/adapters/google-calendar/index.ts +2 -2
- package/src/execution/engine/tools/integration/server/adapters/google-sheets/__tests__/google-sheets.integration.test.ts +261 -261
- package/src/execution/engine/tools/integration/server/adapters/instantly/instantly-tools.ts +1474 -1474
- package/src/execution/engine/tools/integration/server/adapters/millionverifier/millionverifier-tools.ts +103 -103
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/send-email/index.test.ts +88 -88
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/send-email/index.ts +141 -141
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/utils/types.ts +76 -76
- package/src/execution/engine/tools/integration/server/adapters/signature-api/signature-api-tools.ts +182 -182
- package/src/execution/engine/tools/integration/server/adapters/stripe/stripe-tools.ts +310 -310
- package/src/execution/engine/tools/integration/service.test.ts +239 -239
- package/src/execution/engine/tools/integration/service.ts +172 -172
- package/src/execution/engine/tools/integration/tool.ts +255 -255
- package/src/execution/engine/tools/lead-service-types.ts +1005 -1005
- package/src/execution/engine/tools/messages.ts +43 -43
- package/src/execution/engine/tools/platform/acquisition/company-tools.ts +7 -7
- package/src/execution/engine/tools/platform/acquisition/contact-tools.ts +6 -6
- package/src/execution/engine/tools/platform/acquisition/list-tools.ts +6 -6
- package/src/execution/engine/tools/platform/acquisition/types.ts +280 -280
- package/src/execution/engine/tools/platform/email/types.ts +97 -97
- package/src/execution/engine/tools/registry.ts +704 -704
- package/src/execution/engine/tools/tool-maps.ts +831 -831
- package/src/execution/engine/tools/types.ts +234 -234
- package/src/execution/engine/workflow/types.ts +195 -197
- package/src/execution/external/__tests__/api-schemas.test.ts +127 -127
- package/src/execution/external/api-schemas.ts +40 -40
- package/src/execution/external/index.ts +1 -1
- package/src/index.ts +18 -18
- package/src/integrations/credentials/__tests__/api-schemas.test.ts +420 -420
- package/src/integrations/credentials/api-schemas.ts +146 -146
- package/src/integrations/credentials/schemas.ts +200 -200
- package/src/integrations/oauth/__tests__/provider-registry.test.ts +7 -7
- package/src/integrations/oauth/provider-registry.ts +74 -74
- package/src/integrations/oauth/server/credentials.ts +43 -43
- package/src/integrations/webhook-endpoints/__tests__/api-schemas.test.ts +327 -327
- package/src/integrations/webhook-endpoints/api-schemas.ts +103 -103
- package/src/integrations/webhook-endpoints/types.ts +58 -58
- package/src/knowledge/README.md +32 -32
- package/src/knowledge/__tests__/queries.test.ts +626 -535
- package/src/knowledge/format.ts +99 -99
- package/src/knowledge/index.ts +5 -5
- package/src/knowledge/published.ts +5 -5
- package/src/knowledge/queries.ts +269 -218
- package/src/operations/activities/api-schemas.ts +80 -80
- package/src/operations/activities/types.ts +64 -64
- package/src/organization-model/README.md +149 -149
- package/src/organization-model/__tests__/content-kinds-registry.test.ts +210 -210
- package/src/organization-model/__tests__/defaults.test.ts +168 -168
- package/src/organization-model/__tests__/domains/actions.test.ts +78 -56
- package/src/organization-model/__tests__/domains/customers.test.ts +299 -299
- package/src/organization-model/__tests__/domains/entities.test.ts +56 -56
- package/src/organization-model/__tests__/domains/goals.test.ts +493 -493
- package/src/organization-model/__tests__/domains/identity.test.ts +280 -280
- package/src/organization-model/__tests__/domains/navigation.test.ts +268 -268
- package/src/organization-model/__tests__/domains/offerings.test.ts +414 -414
- package/src/organization-model/__tests__/domains/policies.test.ts +323 -323
- package/src/organization-model/__tests__/domains/resource-mappings.test.ts +293 -293
- package/src/organization-model/__tests__/domains/resources.test.ts +387 -277
- package/src/organization-model/__tests__/domains/roles.test.ts +463 -463
- package/src/organization-model/__tests__/domains/statuses.test.ts +246 -246
- package/src/organization-model/__tests__/domains/systems.test.ts +209 -209
- package/src/organization-model/__tests__/domains/topology.test.ts +188 -0
- package/src/organization-model/__tests__/flatten-additive-merge.test.ts +362 -361
- package/src/organization-model/__tests__/foundation.test.ts +77 -77
- package/src/organization-model/__tests__/get-resources-for-system.test.ts +144 -144
- package/src/organization-model/__tests__/graph.test.ts +1312 -862
- package/src/organization-model/__tests__/icons.test.ts +10 -1
- package/src/organization-model/__tests__/knowledge.test.ts +251 -15
- package/src/organization-model/__tests__/lookup-helpers.test.ts +438 -438
- package/src/organization-model/__tests__/migration-helpers.test.ts +591 -591
- package/src/organization-model/__tests__/prospecting-ssot.test.ts +103 -103
- package/src/organization-model/__tests__/recursive-system-schema.test.ts +535 -506
- package/src/organization-model/__tests__/resolve.test.ts +274 -164
- package/src/organization-model/__tests__/schema.test.ts +844 -301
- package/src/organization-model/__tests__/surface-projection.test.ts +284 -284
- package/src/organization-model/catalogs/lead-gen.ts +144 -144
- package/src/organization-model/content-kinds/config.ts +36 -36
- package/src/organization-model/content-kinds/index.ts +76 -72
- package/src/organization-model/content-kinds/pipeline.ts +68 -68
- package/src/organization-model/content-kinds/registry.ts +44 -44
- package/src/organization-model/content-kinds/status.ts +71 -71
- package/src/organization-model/content-kinds/template.ts +83 -83
- package/src/organization-model/content-kinds/types.ts +117 -117
- package/src/organization-model/contracts.ts +27 -27
- package/src/organization-model/defaults.ts +42 -50
- package/src/organization-model/domains/actions.ts +333 -239
- package/src/organization-model/domains/customers.ts +78 -78
- package/src/organization-model/domains/entities.ts +144 -144
- package/src/organization-model/domains/goals.ts +83 -83
- package/src/organization-model/domains/knowledge.ts +117 -101
- package/src/organization-model/domains/navigation.ts +139 -139
- package/src/organization-model/domains/offerings.ts +71 -71
- package/src/organization-model/domains/policies.ts +102 -102
- package/src/organization-model/domains/projects.ts +14 -14
- package/src/organization-model/domains/prospecting.ts +395 -395
- package/src/organization-model/domains/resources.ts +202 -124
- package/src/organization-model/domains/roles.ts +96 -96
- package/src/organization-model/domains/sales.test.ts +218 -218
- package/src/organization-model/domains/sales.ts +380 -380
- package/src/organization-model/domains/shared.ts +63 -63
- package/src/organization-model/domains/statuses.ts +339 -339
- package/src/organization-model/domains/systems.ts +217 -172
- package/src/organization-model/domains/topology.ts +261 -0
- package/src/organization-model/foundation.ts +75 -75
- package/src/organization-model/graph/build.ts +1043 -867
- package/src/organization-model/graph/index.ts +4 -4
- package/src/organization-model/graph/link.ts +10 -10
- package/src/organization-model/graph/schema.ts +75 -68
- package/src/organization-model/graph/types.ts +71 -64
- package/src/organization-model/helpers.ts +289 -241
- package/src/organization-model/icons.ts +78 -66
- package/src/organization-model/index.ts +128 -125
- package/src/organization-model/migration-helpers.ts +247 -244
- package/src/organization-model/ontology.ts +658 -0
- package/src/organization-model/organization-graph.mdx +110 -90
- package/src/organization-model/organization-model.mdx +225 -213
- package/src/organization-model/published.ts +299 -222
- package/src/organization-model/resolve.ts +146 -91
- package/src/organization-model/schema.ts +818 -659
- package/src/organization-model/surface-projection.ts +212 -212
- package/src/organization-model/types.ts +179 -155
- package/src/platform/api/types.ts +38 -38
- package/src/platform/constants/versions.ts +3 -3
- package/src/platform/index.ts +23 -23
- package/src/platform/registry/__tests__/command-view.test.ts +10 -10
- package/src/platform/registry/__tests__/resource-link.test.ts +35 -35
- package/src/platform/registry/__tests__/resource-registry.integration.test.ts +20 -20
- package/src/platform/registry/__tests__/resource-registry.nested-systems.test.ts +245 -245
- package/src/platform/registry/__tests__/resource-registry.test.ts +2053 -2053
- package/src/platform/registry/__tests__/validation.test.ts +1444 -1259
- package/src/platform/registry/command-view.ts +10 -10
- package/src/platform/registry/index.ts +103 -103
- package/src/platform/registry/resource-link.ts +32 -32
- package/src/platform/registry/resource-registry.ts +886 -886
- package/src/platform/registry/serialization.ts +295 -295
- package/src/platform/registry/serialized-types.ts +166 -166
- package/src/platform/registry/stats-types.ts +68 -68
- package/src/platform/registry/types.ts +425 -425
- package/src/platform/registry/validation.ts +876 -684
- package/src/platform/utils/__tests__/validation.test.ts +1084 -1084
- package/src/platform/utils/validation.ts +425 -425
- package/src/projects/api-schemas.test.ts +39 -39
- package/src/projects/api-schemas.ts +291 -291
- package/src/reference/_generated/contracts.md +2136 -2093
- package/src/reference/glossary.md +76 -76
- package/src/scaffold-registry/__tests__/index.test.ts +206 -206
- package/src/scaffold-registry/__tests__/schema.test.ts +166 -166
- package/src/scaffold-registry/index.ts +392 -392
- package/src/scaffold-registry/schema.ts +243 -243
- package/src/server.ts +289 -289
- package/src/supabase/database.types.ts +3 -0
- package/src/test-utils/README.md +37 -37
- package/src/test-utils/entities.ts +108 -108
- package/src/test-utils/fixtures/memberships.ts +82 -82
- package/src/test-utils/index.ts +12 -12
- package/src/test-utils/organization-model.ts +65 -65
- package/src/test-utils/published.ts +6 -6
- package/src/test-utils/rls/RLSTestContext.ts +588 -588
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
203
|
+
expect(() => parseOntologyId('sales.crm/object/deal')).toThrow()
|
|
204
|
+
expect(() => parseOntologyId('sales.crm:unknown/deal')).toThrow()
|
|
67
205
|
})
|
|
68
206
|
|
|
69
|
-
it('
|
|
70
|
-
const
|
|
71
|
-
makeMinimalModel({
|
|
72
|
-
sales: {
|
|
73
|
-
|
|
74
|
-
|
|
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('
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
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('
|
|
96
|
-
const
|
|
97
|
-
|
|
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(
|
|
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('
|
|
104
|
-
const
|
|
316
|
+
it('projects legacy entities, actions, and system content into compiled ontology indexes', () => {
|
|
317
|
+
const model = OrganizationModelSchema.parse(
|
|
105
318
|
makeMinimalModel({
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
it('rejects entities owned by unknown systems', () => {
|
|
393
|
+
it('rejects duplicate ontology ids with source context', () => {
|
|
184
394
|
const messages = getIssueMessages({
|
|
185
|
-
...makeMinimalModel({
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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('
|
|
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('
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
'
|
|
258
|
-
id: '
|
|
479
|
+
}),
|
|
480
|
+
resources: {
|
|
481
|
+
'task-processor': {
|
|
482
|
+
id: 'task-processor',
|
|
259
483
|
order: 10,
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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('
|
|
272
|
-
const
|
|
502
|
+
it('accepts known ontology references inside loose property, input, effect, entry, and payload fields', () => {
|
|
503
|
+
const result = OrganizationModelSchema.safeParse({
|
|
273
504
|
...makeMinimalModel({
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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(
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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(
|
|
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
|
|
604
|
+
it('rejects resource ontology bindings to missing compiled ontology records', () => {
|
|
332
605
|
const messages = getIssueMessages({
|
|
333
606
|
...makeMinimalModel({
|
|
334
|
-
|
|
335
|
-
'sales.crm': makeSystem('sales.crm', '/crm')
|
|
607
|
+
dashboard: makeSystem('dashboard', '/')
|
|
336
608
|
}),
|
|
337
|
-
|
|
338
|
-
'
|
|
339
|
-
|
|
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
|
|
345
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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('
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
+
})
|