@elevasis/core 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/index.d.ts +2330 -2391
  2. package/dist/index.js +2322 -1147
  3. package/dist/knowledge/index.d.ts +702 -1136
  4. package/dist/knowledge/index.js +9 -9
  5. package/dist/organization-model/index.d.ts +2330 -2391
  6. package/dist/organization-model/index.js +2322 -1147
  7. package/dist/test-utils/index.d.ts +703 -1106
  8. package/dist/test-utils/index.js +1735 -1089
  9. package/package.json +1 -1
  10. package/src/__tests__/template-core-compatibility.test.ts +11 -79
  11. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +360 -98
  12. package/src/business/acquisition/api-schemas.test.ts +2 -2
  13. package/src/business/acquisition/api-schemas.ts +7 -9
  14. package/src/business/acquisition/build-templates.test.ts +4 -4
  15. package/src/business/acquisition/build-templates.ts +72 -30
  16. package/src/business/acquisition/crm-state-actions.test.ts +13 -11
  17. package/src/business/acquisition/types.ts +7 -3
  18. package/src/execution/engine/agent/core/types.ts +1 -1
  19. package/src/execution/engine/workflow/types.ts +2 -2
  20. package/src/knowledge/README.md +8 -7
  21. package/src/knowledge/__tests__/queries.test.ts +74 -73
  22. package/src/knowledge/format.ts +10 -9
  23. package/src/knowledge/index.ts +1 -1
  24. package/src/knowledge/published.ts +1 -1
  25. package/src/knowledge/queries.ts +26 -25
  26. package/src/organization-model/README.md +66 -26
  27. package/src/organization-model/__tests__/content-kinds-registry.test.ts +210 -0
  28. package/src/organization-model/__tests__/defaults.test.ts +72 -98
  29. package/src/organization-model/__tests__/domains/actions.test.ts +56 -0
  30. package/src/organization-model/__tests__/domains/customers.test.ts +299 -295
  31. package/src/organization-model/__tests__/domains/entities.test.ts +56 -0
  32. package/src/organization-model/__tests__/domains/goals.test.ts +493 -479
  33. package/src/organization-model/__tests__/domains/identity.test.ts +280 -279
  34. package/src/organization-model/__tests__/domains/navigation.test.ts +268 -212
  35. package/src/organization-model/__tests__/domains/offerings.test.ts +414 -419
  36. package/src/organization-model/__tests__/domains/policies.test.ts +323 -0
  37. package/src/organization-model/__tests__/domains/resource-mappings.test.ts +271 -271
  38. package/src/organization-model/__tests__/domains/resources.test.ts +159 -37
  39. package/src/organization-model/__tests__/domains/roles.test.ts +147 -86
  40. package/src/organization-model/__tests__/domains/statuses.test.ts +246 -243
  41. package/src/organization-model/__tests__/domains/systems.test.ts +67 -51
  42. package/src/organization-model/__tests__/flatten-additive-merge.test.ts +361 -0
  43. package/src/organization-model/__tests__/foundation.test.ts +74 -102
  44. package/src/organization-model/__tests__/get-resources-for-system.test.ts +144 -0
  45. package/src/organization-model/__tests__/graph.test.ts +899 -71
  46. package/src/organization-model/__tests__/knowledge.test.ts +173 -52
  47. package/src/organization-model/__tests__/lookup-helpers.test.ts +438 -0
  48. package/src/organization-model/__tests__/migration-helpers.test.ts +591 -0
  49. package/src/organization-model/__tests__/prospecting-ssot.test.ts +36 -27
  50. package/src/organization-model/__tests__/recursive-system-schema.test.ts +520 -0
  51. package/src/organization-model/__tests__/resolve.test.ts +174 -23
  52. package/src/organization-model/__tests__/schema.test.ts +291 -114
  53. package/src/organization-model/__tests__/surface-projection.test.ts +207 -97
  54. package/src/organization-model/catalogs/lead-gen.ts +144 -0
  55. package/src/organization-model/content-kinds/config.ts +36 -0
  56. package/src/organization-model/content-kinds/index.ts +74 -0
  57. package/src/organization-model/content-kinds/pipeline.ts +68 -0
  58. package/src/organization-model/content-kinds/registry.ts +44 -0
  59. package/src/organization-model/content-kinds/status.ts +71 -0
  60. package/src/organization-model/content-kinds/template.ts +83 -0
  61. package/src/organization-model/content-kinds/types.ts +117 -0
  62. package/src/organization-model/contracts.ts +13 -3
  63. package/src/organization-model/defaults.ts +488 -96
  64. package/src/organization-model/domains/actions.ts +239 -0
  65. package/src/organization-model/domains/customers.ts +78 -75
  66. package/src/organization-model/domains/entities.ts +144 -0
  67. package/src/organization-model/domains/goals.ts +83 -80
  68. package/src/organization-model/domains/knowledge.ts +74 -16
  69. package/src/organization-model/domains/navigation.ts +107 -384
  70. package/src/organization-model/domains/offerings.ts +71 -66
  71. package/src/organization-model/domains/policies.ts +102 -0
  72. package/src/organization-model/domains/projects.ts +14 -48
  73. package/src/organization-model/domains/prospecting.ts +62 -181
  74. package/src/organization-model/domains/resources.ts +81 -24
  75. package/src/organization-model/domains/roles.ts +13 -10
  76. package/src/organization-model/domains/sales.ts +10 -219
  77. package/src/organization-model/domains/shared.ts +57 -57
  78. package/src/organization-model/domains/statuses.ts +339 -130
  79. package/src/organization-model/domains/systems.ts +186 -29
  80. package/src/organization-model/foundation.ts +54 -67
  81. package/src/organization-model/graph/build.ts +682 -54
  82. package/src/organization-model/graph/link.ts +1 -1
  83. package/src/organization-model/graph/schema.ts +24 -9
  84. package/src/organization-model/graph/types.ts +20 -7
  85. package/src/organization-model/helpers.ts +231 -26
  86. package/src/organization-model/index.ts +116 -5
  87. package/src/organization-model/migration-helpers.ts +249 -0
  88. package/src/organization-model/organization-graph.mdx +16 -15
  89. package/src/organization-model/organization-model.mdx +89 -41
  90. package/src/organization-model/published.ts +120 -18
  91. package/src/organization-model/resolve.ts +117 -54
  92. package/src/organization-model/schema.ts +561 -140
  93. package/src/organization-model/surface-projection.ts +116 -122
  94. package/src/organization-model/types.ts +102 -21
  95. package/src/platform/constants/versions.ts +1 -1
  96. package/src/platform/registry/__tests__/command-view.test.ts +6 -8
  97. package/src/platform/registry/__tests__/resource-link.test.ts +13 -8
  98. package/src/platform/registry/__tests__/resource-registry.integration.test.ts +16 -31
  99. package/src/platform/registry/__tests__/resource-registry.nested-systems.test.ts +245 -0
  100. package/src/platform/registry/__tests__/resource-registry.test.ts +9 -7
  101. package/src/platform/registry/__tests__/validation.test.ts +15 -11
  102. package/src/platform/registry/resource-registry.ts +20 -8
  103. package/src/platform/registry/serialization.ts +7 -7
  104. package/src/platform/registry/types.ts +3 -3
  105. package/src/platform/registry/validation.ts +17 -15
  106. package/src/reference/_generated/contracts.md +362 -99
  107. package/src/reference/glossary.md +18 -18
  108. package/src/supabase/database.types.ts +60 -0
  109. package/src/test-utils/test-utils.test.ts +1 -6
  110. package/src/organization-model/__tests__/domains/operations.test.ts +0 -203
  111. package/src/organization-model/domains/features.ts +0 -31
  112. package/src/organization-model/domains/operations.ts +0 -85
@@ -1,29 +1,91 @@
1
1
  import { z } from 'zod'
2
+ import { lookupContentType } from './content-kinds/index'
2
3
  import { OrganizationModelBrandingSchema } from './domains/branding'
3
- import { OrganizationModelSalesSchema } from './domains/sales'
4
- import { OrganizationModelProjectsSchema } from './domains/projects'
5
- import { FeatureSchema } from './domains/features'
6
- import { OrganizationModelProspectingSchema } from './domains/prospecting'
7
- import { OrganizationModelNavigationSchema } from './domains/navigation'
4
+ import { OrganizationModelNavigationSchema, type SidebarNode } from './domains/navigation'
8
5
  import { IdentityDomainSchema, DEFAULT_ORGANIZATION_MODEL_IDENTITY } from './domains/identity'
9
6
  import { CustomersDomainSchema, DEFAULT_ORGANIZATION_MODEL_CUSTOMERS } from './domains/customers'
10
7
  import { OfferingsDomainSchema, DEFAULT_ORGANIZATION_MODEL_OFFERINGS } from './domains/offerings'
11
8
  import { RolesDomainSchema, DEFAULT_ORGANIZATION_MODEL_ROLES } from './domains/roles'
12
9
  import { GoalsDomainSchema, DEFAULT_ORGANIZATION_MODEL_GOALS } from './domains/goals'
13
- import { OperationsDomainSchema } from './domains/operations'
14
- import { StatusesDomainSchema } from './domains/statuses'
15
10
  import { KnowledgeDomainSchema } from './domains/knowledge'
16
- import { SystemsDomainSchema, DEFAULT_ORGANIZATION_MODEL_SYSTEMS } from './domains/systems'
11
+ import { SystemsDomainSchema, DEFAULT_ORGANIZATION_MODEL_SYSTEMS, type SystemEntry } from './domains/systems'
17
12
  import { ResourcesDomainSchema, DEFAULT_ORGANIZATION_MODEL_RESOURCES } from './domains/resources'
13
+ import { ActionsDomainSchema, DEFAULT_ORGANIZATION_MODEL_ACTIONS } from './domains/actions'
14
+ import { EntitiesDomainSchema, DEFAULT_ORGANIZATION_MODEL_ENTITIES } from './domains/entities'
15
+ import { PoliciesDomainSchema, DEFAULT_ORGANIZATION_MODEL_POLICIES } from './domains/policies'
16
+
17
+ // Phase 4 cut: 'sales', 'prospecting', 'projects', 'statuses' removed.
18
+ // domainMetadata.knowledge covers versioning for the knowledge flat-map (D7).
19
+ export const OrganizationModelDomainKeySchema = z.enum([
20
+ 'branding',
21
+ 'identity',
22
+ 'customers',
23
+ 'offerings',
24
+ 'roles',
25
+ 'goals',
26
+ 'systems',
27
+ 'resources',
28
+ 'actions',
29
+ 'entities',
30
+ 'policies',
31
+ 'knowledge'
32
+ ])
33
+
34
+ export const OrganizationModelDomainMetadataSchema = z.object({
35
+ version: z.literal(1).default(1),
36
+ lastModified: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'lastModified must be an ISO date string (YYYY-MM-DD)')
37
+ })
38
+
39
+ export const DEFAULT_ORGANIZATION_MODEL_DOMAIN_METADATA: Record<
40
+ z.infer<typeof OrganizationModelDomainKeySchema>,
41
+ z.infer<typeof OrganizationModelDomainMetadataSchema>
42
+ > = {
43
+ branding: { version: 1, lastModified: '2026-05-10' },
44
+ identity: { version: 1, lastModified: '2026-05-10' },
45
+ customers: { version: 1, lastModified: '2026-05-10' },
46
+ offerings: { version: 1, lastModified: '2026-05-10' },
47
+ roles: { version: 1, lastModified: '2026-05-10' },
48
+ goals: { version: 1, lastModified: '2026-05-10' },
49
+ systems: { version: 1, lastModified: '2026-05-10' },
50
+ resources: { version: 1, lastModified: '2026-05-10' },
51
+ actions: { version: 1, lastModified: '2026-05-10' },
52
+ entities: { version: 1, lastModified: '2026-05-10' },
53
+ policies: { version: 1, lastModified: '2026-05-10' },
54
+ knowledge: { version: 1, lastModified: '2026-05-10' }
55
+ }
18
56
 
57
+ export const OrganizationModelDomainMetadataByDomainSchema = z
58
+ .object({
59
+ branding: OrganizationModelDomainMetadataSchema,
60
+ identity: OrganizationModelDomainMetadataSchema,
61
+ customers: OrganizationModelDomainMetadataSchema,
62
+ offerings: OrganizationModelDomainMetadataSchema,
63
+ roles: OrganizationModelDomainMetadataSchema,
64
+ goals: OrganizationModelDomainMetadataSchema,
65
+ systems: OrganizationModelDomainMetadataSchema,
66
+ resources: OrganizationModelDomainMetadataSchema,
67
+ actions: OrganizationModelDomainMetadataSchema,
68
+ entities: OrganizationModelDomainMetadataSchema,
69
+ policies: OrganizationModelDomainMetadataSchema,
70
+ knowledge: OrganizationModelDomainMetadataSchema
71
+ })
72
+ .partial()
73
+ .default(DEFAULT_ORGANIZATION_MODEL_DOMAIN_METADATA)
74
+ .transform((metadata) => ({ ...DEFAULT_ORGANIZATION_MODEL_DOMAIN_METADATA, ...metadata }))
75
+
76
+ // Phase 4 schema cut (D8):
77
+ // REMOVED top-level fields: sales, prospecting, projects, statuses
78
+ // ADDED navigation.sidebar as the authored shell tree
79
+ // knowledge: now Record<id, OrgKnowledgeNode> flat map (D3); version/lastModified in domainMetadata.knowledge (D7)
80
+ //
81
+ // Clean migration rule:
82
+ // NO top-level surfaces / navigationGroups compatibility fields.
83
+ // Surfaces are derived from navigation.sidebar routeable leaves.
19
84
  const OrganizationModelSchemaBase = z.object({
20
85
  version: z.literal(1).default(1),
21
- features: z.array(FeatureSchema).default([]),
86
+ domainMetadata: OrganizationModelDomainMetadataByDomainSchema,
22
87
  branding: OrganizationModelBrandingSchema,
23
- navigation: OrganizationModelNavigationSchema.default({ surfaces: [], groups: [] }),
24
- sales: OrganizationModelSalesSchema,
25
- prospecting: OrganizationModelProspectingSchema,
26
- projects: OrganizationModelProjectsSchema,
88
+ navigation: OrganizationModelNavigationSchema,
27
89
  identity: IdentityDomainSchema.default(DEFAULT_ORGANIZATION_MODEL_IDENTITY),
28
90
  customers: CustomersDomainSchema.default(DEFAULT_ORGANIZATION_MODEL_CUSTOMERS),
29
91
  offerings: OfferingsDomainSchema.default(DEFAULT_ORGANIZATION_MODEL_OFFERINGS),
@@ -31,9 +93,11 @@ const OrganizationModelSchemaBase = z.object({
31
93
  goals: GoalsDomainSchema.default(DEFAULT_ORGANIZATION_MODEL_GOALS),
32
94
  systems: SystemsDomainSchema.default(DEFAULT_ORGANIZATION_MODEL_SYSTEMS),
33
95
  resources: ResourcesDomainSchema.default(DEFAULT_ORGANIZATION_MODEL_RESOURCES),
34
- statuses: StatusesDomainSchema.default({ entries: [] }),
35
- operations: OperationsDomainSchema.default({ entries: [] }),
36
- knowledge: KnowledgeDomainSchema.default({ nodes: [] })
96
+ actions: ActionsDomainSchema.default(DEFAULT_ORGANIZATION_MODEL_ACTIONS),
97
+ entities: EntitiesDomainSchema.default(DEFAULT_ORGANIZATION_MODEL_ENTITIES),
98
+ policies: PoliciesDomainSchema.default(DEFAULT_ORGANIZATION_MODEL_POLICIES),
99
+ // D3: flat Record<id, OrgKnowledgeNode> — no wrapper object
100
+ knowledge: KnowledgeDomainSchema.default({})
37
101
  })
38
102
 
39
103
  function addIssue(ctx: z.RefinementCtx, path: Array<string | number>, message: string): void {
@@ -44,243 +108,493 @@ function addIssue(ctx: z.RefinementCtx, path: Array<string | number>, message: s
44
108
  })
45
109
  }
46
110
 
47
- function collectIds<T extends { id: string }>(
48
- items: T[],
49
- ctx: z.RefinementCtx,
50
- collectionPath: Array<string | number>,
51
- label: string
52
- ): Map<string, T> {
53
- const itemsById = new Map<string, T>()
54
-
55
- items.forEach((item, index) => {
56
- if (itemsById.has(item.id)) {
57
- addIssue(ctx, [...collectionPath, index, 'id'], `${label} id "${item.id}" must be unique`)
58
- return
59
- }
60
-
61
- itemsById.set(item.id, item)
62
- })
63
-
64
- return itemsById
65
- }
66
-
67
- const LEGACY_FEATURE_ALIASES = new Map<string, string>([
68
- ['crm', 'sales.crm'],
69
- ['lead-gen', 'sales.lead-gen'],
70
- ['submitted-requests', 'monitoring.submitted-requests']
71
- ])
72
-
73
- function hasFeature(featuresById: Map<string, unknown>, featureId: string): boolean {
74
- return featuresById.has(featureId) || featuresById.has(LEGACY_FEATURE_ALIASES.get(featureId) ?? '')
111
+ function isLifecycleEnabled(lifecycle: string | undefined, enabled: boolean | undefined): boolean {
112
+ if (enabled === false) return false
113
+ return lifecycle !== 'deprecated' && lifecycle !== 'archived'
75
114
  }
76
115
 
77
- function defaultFeaturePathFor(id: string): string {
116
+ function defaultSystemPathFor(id: string): string {
78
117
  return `/${id.replaceAll('.', '/')}`
79
118
  }
80
119
 
81
120
  function asRoleHolderArray(
82
- heldBy: NonNullable<z.infer<typeof OrganizationModelSchemaBase>['roles']['roles'][number]['heldBy']>
121
+ heldBy: NonNullable<z.infer<typeof OrganizationModelSchemaBase>['roles'][string]['heldBy']>
83
122
  ) {
84
123
  return Array.isArray(heldBy) ? heldBy : [heldBy]
85
124
  }
86
125
 
126
+ function isKnowledgeKindCompatibleWithTarget(knowledgeKind: string, targetKind: string): boolean {
127
+ if (knowledgeKind === 'reference') return true
128
+ if (knowledgeKind === 'playbook') {
129
+ return ['system', 'resource', 'stage', 'action'].includes(targetKind)
130
+ }
131
+ if (knowledgeKind === 'strategy') {
132
+ return ['system', 'goal', 'offering', 'customer-segment'].includes(targetKind)
133
+ }
134
+ return false
135
+ }
136
+
87
137
  export const OrganizationModelSchema = OrganizationModelSchemaBase.superRefine((model, ctx) => {
88
- const featuresById = collectIds(model.features, ctx, ['features'], 'Feature')
89
- const featureIdsByEffectivePath = new Map<string, string>()
90
-
91
- model.features.forEach((feature, featureIndex) => {
92
- const segments = feature.id.split('.')
93
- if (segments.length > 1) {
94
- const parentId = segments.slice(0, -1).join('.')
95
- if (!featuresById.has(parentId)) {
96
- addIssue(
97
- ctx,
98
- ['features', featureIndex, 'id'],
99
- `Feature "${feature.id}" references unknown parent "${parentId}"`
100
- )
138
+ // Collect ALL system entries recursively — top-level systems plus any nested subsystems.
139
+ // Wave 2 canonical OM authors nested subsystems (e.g. sys → subsystems → 'lead-gen' with id
140
+ // 'sys.lead-gen'). Resource systemPath cross-refs must resolve against the full flattened set.
141
+ type SystemWithPath = { path: string; schemaPath: Array<string | number>; system: SystemEntry }
142
+
143
+ function collectAllSystems(
144
+ systems: Record<string, SystemEntry>,
145
+ prefix = '',
146
+ schemaPath: Array<string | number> = ['systems']
147
+ ): SystemWithPath[] {
148
+ const result: SystemWithPath[] = []
149
+ for (const [key, system] of Object.entries(systems)) {
150
+ const path = prefix ? `${prefix}.${key}` : key
151
+ const currentSchemaPath = [...schemaPath, key]
152
+ result.push({ path, schemaPath: currentSchemaPath, system })
153
+ if (system.subsystems !== undefined) {
154
+ result.push(...collectAllSystems(system.subsystems, path, [...currentSchemaPath, 'subsystems']))
101
155
  }
102
156
  }
157
+ return result
158
+ }
103
159
 
104
- const hasChildren = model.features.some(
105
- (candidate) => candidate.id.startsWith(`${feature.id}.`) && candidate.id !== feature.id
106
- )
107
- const contributesRoutePath = feature.path !== undefined || !hasChildren
160
+ const allSystems = collectAllSystems(model.systems)
161
+ const systemsById = new Map<string, SystemEntry>()
162
+ for (const { path, system } of allSystems) {
163
+ systemsById.set(path, system)
164
+ systemsById.set(system.id, system)
165
+ }
166
+
167
+ const systemIdsByEffectivePath = new Map<string, string>()
168
+ allSystems.forEach(({ path, schemaPath, system }) => {
169
+ if (system.parentSystemId !== undefined && !systemsById.has(system.parentSystemId)) {
170
+ addIssue(
171
+ ctx,
172
+ [...schemaPath, 'parentSystemId'],
173
+ `System "${system.id}" references unknown parent "${system.parentSystemId}"`
174
+ )
175
+ }
176
+
177
+ const hasChildren =
178
+ Object.keys(system.subsystems ?? {}).length > 0 ||
179
+ allSystems.some((candidate) => candidate.path.startsWith(`${path}.`) && !candidate.path.slice(path.length + 1).includes('.'))
180
+ const contributesRoutePath = system.ui?.path !== undefined || system.path !== undefined || !hasChildren
108
181
  if (contributesRoutePath) {
109
- const effectivePath = feature.path ?? defaultFeaturePathFor(feature.id)
110
- const existingFeatureId = featureIdsByEffectivePath.get(effectivePath)
111
- if (existingFeatureId !== undefined) {
182
+ const effectivePath = system.ui?.path ?? system.path ?? defaultSystemPathFor(path)
183
+ const existingSystemId = systemIdsByEffectivePath.get(effectivePath)
184
+ if (existingSystemId !== undefined) {
112
185
  addIssue(
113
186
  ctx,
114
- ['features', featureIndex, feature.path === undefined ? 'id' : 'path'],
115
- `Feature "${feature.id}" effective path "${effectivePath}" duplicates feature "${existingFeatureId}"`
187
+ [...schemaPath, system.ui?.path !== undefined ? 'ui' : 'path'],
188
+ `System "${path}" effective path "${effectivePath}" duplicates system "${existingSystemId}"`
116
189
  )
117
190
  } else {
118
- featureIdsByEffectivePath.set(effectivePath, feature.id)
191
+ systemIdsByEffectivePath.set(effectivePath, path)
119
192
  }
120
193
  }
121
194
 
122
- if (hasChildren && feature.enabled) {
123
- const hasEnabledDescendant = model.features.some(
124
- (candidate) => candidate.id.startsWith(`${feature.id}.`) && candidate.enabled
125
- )
195
+ if (hasChildren && isLifecycleEnabled(system.lifecycle, system.enabled)) {
196
+ const hasEnabledDescendant =
197
+ Object.values(system.subsystems ?? {}).some((candidate) =>
198
+ isLifecycleEnabled(candidate.lifecycle, candidate.enabled)
199
+ ) ||
200
+ allSystems.some(
201
+ (candidate) =>
202
+ candidate.path.startsWith(`${path}.`) &&
203
+ !candidate.path.slice(path.length + 1).includes('.') &&
204
+ isLifecycleEnabled(candidate.system.lifecycle, candidate.system.enabled)
205
+ )
126
206
  if (!hasEnabledDescendant) {
127
207
  addIssue(
128
208
  ctx,
129
- ['features', featureIndex, 'enabled'],
130
- `Feature "${feature.id}" is enabled but has no enabled descendants`
209
+ [...schemaPath, 'lifecycle'],
210
+ `System "${path}" is active but has no active descendants`
131
211
  )
132
212
  }
133
213
  }
134
214
  })
135
215
 
136
- const surfacesById = collectIds(model.navigation.surfaces, ctx, ['navigation', 'surfaces'], 'Navigation surface')
216
+ allSystems.forEach(({ schemaPath, system }) => {
217
+ const visited = new Set<string>()
218
+ let currentParentId = system.parentSystemId
219
+
220
+ while (currentParentId !== undefined) {
221
+ if (currentParentId === system.id || visited.has(currentParentId)) {
222
+ addIssue(ctx, [...schemaPath, 'parentSystemId'], `System "${system.id}" has a parent cycle`)
223
+ return
224
+ }
137
225
 
138
- if (model.navigation.defaultSurfaceId !== undefined && !surfacesById.has(model.navigation.defaultSurfaceId)) {
139
- addIssue(
140
- ctx,
141
- ['navigation', 'defaultSurfaceId'],
142
- `Navigation defaultSurfaceId references unknown surface "${model.navigation.defaultSurfaceId}"`
143
- )
226
+ visited.add(currentParentId)
227
+ currentParentId = systemsById.get(currentParentId)?.parentSystemId
228
+ }
229
+ })
230
+
231
+ type CollectedSidebarSurface = {
232
+ id: string
233
+ node: Extract<SidebarNode, { type: 'surface' }>
234
+ path: Array<string | number>
144
235
  }
145
236
 
146
- model.navigation.groups.forEach((group, groupIndex) => {
147
- group.surfaceIds.forEach((surfaceId, surfaceIndex) => {
148
- if (!surfacesById.has(surfaceId)) {
149
- addIssue(
150
- ctx,
151
- ['navigation', 'groups', groupIndex, 'surfaceIds', surfaceIndex],
152
- `Navigation group "${group.id}" references unknown surface "${surfaceId}"`
153
- )
237
+ function normalizeRoutePath(path: string): string {
238
+ return path.length > 1 ? path.replace(/\/+$/, '') : path
239
+ }
240
+
241
+ const sidebarNodeIds = new Map<string, Array<string | number>>()
242
+ const sidebarSurfacePaths = new Map<string, string>()
243
+ const sidebarSurfaces: CollectedSidebarSurface[] = []
244
+
245
+ function collectSidebarNodes(
246
+ nodes: Record<string, SidebarNode>,
247
+ schemaPath: Array<string | number>
248
+ ): void {
249
+ Object.entries(nodes).forEach(([nodeId, node]) => {
250
+ const nodePath = [...schemaPath, nodeId]
251
+ const existingNodePath = sidebarNodeIds.get(nodeId)
252
+ if (existingNodePath !== undefined) {
253
+ addIssue(ctx, nodePath, `Sidebar node id "${nodeId}" duplicates another sidebar node`)
254
+ } else {
255
+ sidebarNodeIds.set(nodeId, nodePath)
154
256
  }
155
- })
156
- })
157
257
 
158
- model.navigation.surfaces.forEach((surface, surfaceIndex) => {
159
- if (surface.featureId !== undefined && !hasFeature(featuresById, surface.featureId)) {
160
- addIssue(
161
- ctx,
162
- ['navigation', 'surfaces', surfaceIndex, 'featureId'],
163
- `Navigation surface "${surface.id}" references unknown feature "${surface.featureId}"`
164
- )
165
- }
258
+ if (node.type === 'group') {
259
+ collectSidebarNodes(node.children, [...nodePath, 'children'])
260
+ return
261
+ }
166
262
 
167
- surface.featureIds.forEach((featureId, featureIndex) => {
168
- if (!hasFeature(featuresById, featureId)) {
263
+ sidebarSurfaces.push({ id: nodeId, node, path: nodePath })
264
+ const normalizedPath = normalizeRoutePath(node.path)
265
+ const existingSurfaceId = sidebarSurfacePaths.get(normalizedPath)
266
+ if (existingSurfaceId !== undefined) {
169
267
  addIssue(
170
268
  ctx,
171
- ['navigation', 'surfaces', surfaceIndex, 'featureIds', featureIndex],
172
- `Navigation surface "${surface.id}" references unknown feature "${featureId}"`
269
+ [...nodePath, 'path'],
270
+ `Sidebar surface path "${node.path}" duplicates surface "${existingSurfaceId}"`
173
271
  )
272
+ } else {
273
+ sidebarSurfacePaths.set(normalizedPath, nodeId)
174
274
  }
275
+
276
+ node.targets?.systems?.forEach((systemId, systemIndex) => {
277
+ if (!systemsById.has(systemId)) {
278
+ addIssue(
279
+ ctx,
280
+ [...nodePath, 'targets', 'systems', systemIndex],
281
+ `Sidebar surface "${nodeId}" references unknown system "${systemId}"`
282
+ )
283
+ }
284
+ })
175
285
  })
176
- })
286
+ }
287
+
288
+ collectSidebarNodes(model.navigation.sidebar.primary, ['navigation', 'sidebar', 'primary'])
289
+ collectSidebarNodes(model.navigation.sidebar.bottom, ['navigation', 'sidebar', 'bottom'])
177
290
 
178
291
  // Offerings -> CustomerSegment cross-ref: targetSegmentIds must resolve
179
- const segmentsById = new Map(model.customers.segments.map((seg) => [seg.id, seg]))
180
- model.offerings.products.forEach((product, productIndex) => {
292
+ const segmentsById = new Map(Object.entries(model.customers))
293
+ Object.values(model.offerings).forEach((product) => {
181
294
  product.targetSegmentIds.forEach((segmentId, segmentIndex) => {
182
295
  if (!segmentsById.has(segmentId)) {
183
296
  addIssue(
184
297
  ctx,
185
- ['offerings', 'products', productIndex, 'targetSegmentIds', segmentIndex],
298
+ ['offerings', product.id, 'targetSegmentIds', segmentIndex],
186
299
  `Product "${product.id}" references unknown customer segment "${segmentId}"`
187
300
  )
188
301
  }
189
302
  })
190
303
 
191
- // Offerings -> Feature cross-ref: deliveryFeatureId must resolve (when present)
192
- if (product.deliveryFeatureId !== undefined && !hasFeature(featuresById, product.deliveryFeatureId)) {
304
+ // Offerings -> System cross-ref: deliveryFeatureId must resolve (when present)
305
+ if (product.deliveryFeatureId !== undefined && !systemsById.has(product.deliveryFeatureId)) {
193
306
  addIssue(
194
307
  ctx,
195
- ['offerings', 'products', productIndex, 'deliveryFeatureId'],
196
- `Product "${product.id}" references unknown delivery feature "${product.deliveryFeatureId}"`
308
+ ['offerings', product.id, 'deliveryFeatureId'],
309
+ `Product "${product.id}" references unknown delivery system "${product.deliveryFeatureId}"`
197
310
  )
198
311
  }
199
312
  })
200
313
 
201
314
  // Goals -> period-range validation: periodEnd must be strictly after periodStart
202
- model.goals.objectives.forEach((objective, index) => {
315
+ Object.values(model.goals).forEach((objective) => {
203
316
  if (objective.periodEnd <= objective.periodStart) {
204
317
  addIssue(
205
318
  ctx,
206
- ['goals', 'objectives', index, 'periodEnd'],
319
+ ['goals', objective.id, 'periodEnd'],
207
320
  `Goal "${objective.id}" has periodEnd "${objective.periodEnd}" which must be strictly after periodStart "${objective.periodStart}"`
208
321
  )
209
322
  }
210
323
  })
211
324
 
212
- const goalsById = new Map(model.goals.objectives.map((objective) => [objective.id, objective]))
213
- const knowledgeById = new Map(model.knowledge.nodes.map((node) => [node.id, node]))
325
+ const goalsById = new Map(Object.entries(model.goals))
326
+ // Phase 4: knowledge is now a flat Record<id, OrgKnowledgeNode> — no .nodes array
327
+ const knowledgeById = new Map(Object.entries(model.knowledge))
328
+ const actionsById = new Map(Object.entries(model.actions))
329
+ const entitiesById = new Map(Object.entries(model.entities))
330
+ const policiesById = new Map(Object.entries(model.policies))
331
+
332
+ sidebarSurfaces.forEach(({ id, node, path }) => {
333
+ node.targets?.entities?.forEach((entityId, entityIndex) => {
334
+ if (!entitiesById.has(entityId)) {
335
+ addIssue(
336
+ ctx,
337
+ [...path, 'targets', 'entities', entityIndex],
338
+ `Sidebar surface "${id}" references unknown entity "${entityId}"`
339
+ )
340
+ }
341
+ })
342
+
343
+ node.targets?.actions?.forEach((actionId, actionIndex) => {
344
+ if (!actionsById.has(actionId)) {
345
+ addIssue(
346
+ ctx,
347
+ [...path, 'targets', 'actions', actionIndex],
348
+ `Sidebar surface "${id}" references unknown action "${actionId}"`
349
+ )
350
+ }
351
+ })
352
+ })
353
+
354
+ Object.values(model.entities).forEach((entity) => {
355
+ if (!systemsById.has(entity.ownedBySystemId)) {
356
+ addIssue(
357
+ ctx,
358
+ ['entities', entity.id, 'ownedBySystemId'],
359
+ `Entity "${entity.id}" references unknown ownedBySystemId "${entity.ownedBySystemId}"`
360
+ )
361
+ }
362
+
363
+ entity.links?.forEach((link, linkIndex) => {
364
+ if (!entitiesById.has(link.toEntity)) {
365
+ addIssue(
366
+ ctx,
367
+ ['entities', entity.id, 'links', linkIndex, 'toEntity'],
368
+ `Entity "${entity.id}" links to unknown entity "${link.toEntity}"`
369
+ )
370
+ }
371
+ })
372
+ })
214
373
 
215
374
  // Roles -> reportsToId cross-ref: each reportsToId must resolve to another role in the same collection
216
- const rolesById = new Map(model.roles.roles.map((role) => [role.id, role]))
217
- model.roles.roles.forEach((role, roleIndex) => {
375
+ const rolesById = new Map(Object.entries(model.roles))
376
+ Object.values(model.roles).forEach((role) => {
218
377
  if (role.reportsToId !== undefined && !rolesById.has(role.reportsToId)) {
219
378
  addIssue(
220
379
  ctx,
221
- ['roles', 'roles', roleIndex, 'reportsToId'],
380
+ ['roles', role.id, 'reportsToId'],
222
381
  `Role "${role.id}" references unknown reportsToId "${role.reportsToId}"`
223
382
  )
224
383
  }
225
384
  })
226
385
 
227
- const systemsById = collectIds(model.systems.systems, ctx, ['systems', 'systems'], 'System')
228
- model.roles.roles.forEach((role, roleIndex) => {
386
+ Object.values(model.roles).forEach((role) => {
387
+ const visited = new Set<string>()
388
+ let currentReportsToId = role.reportsToId
389
+
390
+ while (currentReportsToId !== undefined) {
391
+ if (currentReportsToId === role.id || visited.has(currentReportsToId)) {
392
+ addIssue(ctx, ['roles', role.id, 'reportsToId'], `Role "${role.id}" has a reportsToId cycle`)
393
+ return
394
+ }
395
+
396
+ visited.add(currentReportsToId)
397
+ currentReportsToId = rolesById.get(currentReportsToId)?.reportsToId
398
+ }
399
+ })
400
+
401
+ Object.values(model.roles).forEach((role) => {
229
402
  role.responsibleFor?.forEach((systemId, systemIndex) => {
230
403
  if (!systemsById.has(systemId)) {
231
404
  addIssue(
232
405
  ctx,
233
- ['roles', 'roles', roleIndex, 'responsibleFor', systemIndex],
406
+ ['roles', role.id, 'responsibleFor', systemIndex],
234
407
  `Role "${role.id}" references unknown responsibleFor system "${systemId}"`
235
408
  )
236
409
  }
237
410
  })
238
411
  })
239
412
 
240
- model.systems.systems.forEach((system, systemIndex) => {
413
+ allSystems.forEach(({ schemaPath, system }) => {
241
414
  if (system.responsibleRoleId !== undefined && !rolesById.has(system.responsibleRoleId)) {
242
415
  addIssue(
243
416
  ctx,
244
- ['systems', 'systems', systemIndex, 'responsibleRoleId'],
417
+ [...schemaPath, 'responsibleRoleId'],
245
418
  `System "${system.id}" references unknown responsibleRoleId "${system.responsibleRoleId}"`
246
419
  )
247
420
  }
248
421
 
249
- system.governedByKnowledge.forEach((nodeId, nodeIndex) => {
422
+ system.governedByKnowledge?.forEach((nodeId, nodeIndex) => {
250
423
  if (!knowledgeById.has(nodeId)) {
251
424
  addIssue(
252
425
  ctx,
253
- ['systems', 'systems', systemIndex, 'governedByKnowledge', nodeIndex],
426
+ [...schemaPath, 'governedByKnowledge', nodeIndex],
254
427
  `System "${system.id}" references unknown knowledge node "${nodeId}"`
255
428
  )
256
429
  }
257
430
  })
258
431
 
259
- system.drivesGoals.forEach((goalId, goalIndex) => {
432
+ system.drivesGoals?.forEach((goalId, goalIndex) => {
260
433
  if (!goalsById.has(goalId)) {
261
434
  addIssue(
262
435
  ctx,
263
- ['systems', 'systems', systemIndex, 'drivesGoals', goalIndex],
436
+ [...schemaPath, 'drivesGoals', goalIndex],
264
437
  `System "${system.id}" references unknown goal "${goalId}"`
265
438
  )
266
439
  }
267
440
  })
441
+
442
+ system.actions?.forEach((actionRef, actionIndex) => {
443
+ if (!actionsById.has(actionRef.actionId)) {
444
+ addIssue(
445
+ ctx,
446
+ [...schemaPath, 'actions', actionIndex, 'actionId'],
447
+ `System "${system.id}" references unknown action "${actionRef.actionId}"`
448
+ )
449
+ }
450
+ })
451
+
452
+ system.policies?.forEach((policyId, policyIndex) => {
453
+ if (!policiesById.has(policyId)) {
454
+ addIssue(
455
+ ctx,
456
+ [...schemaPath, 'policies', policyIndex],
457
+ `System "${system.id}" references unknown policy "${policyId}"`
458
+ )
459
+ }
460
+ })
461
+ })
462
+
463
+ Object.values(model.actions).forEach((action) => {
464
+ action.affects?.forEach((entityId, entityIndex) => {
465
+ if (!entitiesById.has(entityId)) {
466
+ addIssue(
467
+ ctx,
468
+ ['actions', action.id, 'affects', entityIndex],
469
+ `Action "${action.id}" affects unknown entity "${entityId}"`
470
+ )
471
+ }
472
+ })
473
+ })
474
+
475
+ // Phase 4: sales / prospecting / projects compound-domain entity cross-ref checks removed.
476
+ // Those entity bindings now live in system.content (Wave 2 canonicalOrganizationModel).
477
+
478
+ const resourcesById = new Map(Object.entries(model.resources))
479
+ sidebarSurfaces.forEach(({ id, node, path }) => {
480
+ node.targets?.resources?.forEach((resourceId, resourceIndex) => {
481
+ if (!resourcesById.has(resourceId)) {
482
+ addIssue(
483
+ ctx,
484
+ [...path, 'targets', 'resources', resourceIndex],
485
+ `Sidebar surface "${id}" references unknown resource "${resourceId}"`
486
+ )
487
+ }
488
+ })
489
+ })
490
+ // Phase 4: stageIds previously sourced from model.prospecting.*Stages; stages now live in
491
+ // system.content as schema:stage nodes. knowledge 'stage' target validation is kept permissive
492
+ // (always false) — Wave 2 will wire a content-based stage lookup when canonical OM lands.
493
+ const stageIds = new Set<string>()
494
+ const actionIds = new Set(Object.keys(model.actions))
495
+ const offeringsById = new Map(Object.entries(model.offerings))
496
+
497
+ Object.values(model.policies).forEach((policy) => {
498
+ policy.appliesTo.systemIds.forEach((systemId, systemIndex) => {
499
+ if (!systemsById.has(systemId)) {
500
+ addIssue(
501
+ ctx,
502
+ ['policies', policy.id, 'appliesTo', 'systemIds', systemIndex],
503
+ `Policy "${policy.id}" applies to unknown system "${systemId}"`
504
+ )
505
+ }
506
+ })
507
+
508
+ policy.appliesTo.actionIds.forEach((actionId, actionIndex) => {
509
+ if (!actionsById.has(actionId)) {
510
+ addIssue(
511
+ ctx,
512
+ ['policies', policy.id, 'appliesTo', 'actionIds', actionIndex],
513
+ `Policy "${policy.id}" applies to unknown action "${actionId}"`
514
+ )
515
+ }
516
+ })
517
+
518
+ policy.actions.forEach((action, actionIndex) => {
519
+ if (action.kind === 'invoke-action' && !actionsById.has(action.actionId)) {
520
+ addIssue(
521
+ ctx,
522
+ ['policies', policy.id, 'actions', actionIndex, 'actionId'],
523
+ `Policy "${policy.id}" invokes unknown action "${action.actionId}"`
524
+ )
525
+ }
526
+ if (
527
+ (action.kind === 'notify-role' || action.kind === 'require-approval') &&
528
+ action.roleId !== undefined &&
529
+ !rolesById.has(action.roleId)
530
+ ) {
531
+ addIssue(
532
+ ctx,
533
+ ['policies', policy.id, 'actions', actionIndex, 'roleId'],
534
+ `Policy "${policy.id}" references unknown role "${action.roleId}"`
535
+ )
536
+ }
537
+ })
538
+
539
+ if (policy.trigger.kind === 'action-invocation' && !actionsById.has(policy.trigger.actionId)) {
540
+ addIssue(
541
+ ctx,
542
+ ['policies', policy.id, 'trigger', 'actionId'],
543
+ `Policy "${policy.id}" references unknown trigger action "${policy.trigger.actionId}"`
544
+ )
545
+ }
268
546
  })
269
547
 
270
- const resourcesById = collectIds(model.resources.entries, ctx, ['resources', 'entries'], 'Resource')
271
- model.resources.entries.forEach((resource, resourceIndex) => {
272
- if (!systemsById.has(resource.systemId)) {
548
+ function knowledgeTargetExists(kind: string, id: string): boolean {
549
+ if (kind === 'system') return systemsById.has(id)
550
+ if (kind === 'resource') return resourcesById.has(id)
551
+ if (kind === 'knowledge') return knowledgeById.has(id)
552
+ if (kind === 'stage') return stageIds.has(id)
553
+ if (kind === 'action') return actionIds.has(id)
554
+ if (kind === 'role') return rolesById.has(id)
555
+ if (kind === 'goal') return goalsById.has(id)
556
+ if (kind === 'customer-segment') return segmentsById.has(id)
557
+ if (kind === 'offering') return offeringsById.has(id)
558
+ return false
559
+ }
560
+
561
+ // Phase 4: model.knowledge is now Record<id, OrgKnowledgeNode> — iterate Object.values
562
+ Object.entries(model.knowledge).forEach(([nodeId, node]) => {
563
+ node.links.forEach((link, linkIndex) => {
564
+ if (!knowledgeTargetExists(link.target.kind, link.target.id)) {
565
+ addIssue(
566
+ ctx,
567
+ ['knowledge', nodeId, 'links', linkIndex, 'target'],
568
+ `Knowledge node "${node.id}" references unknown ${link.target.kind} target "${link.target.id}"`
569
+ )
570
+ }
571
+
572
+ if (!isKnowledgeKindCompatibleWithTarget(node.kind, link.target.kind)) {
573
+ addIssue(
574
+ ctx,
575
+ ['knowledge', nodeId, 'links', linkIndex, 'target', 'kind'],
576
+ `Knowledge node "${node.id}" kind "${node.kind}" cannot govern ${link.target.kind} targets`
577
+ )
578
+ }
579
+
580
+ // `governedByKnowledge` is validated one-way on target nodes above. Knowledge
581
+ // links may be authored first and remain valid as forward references.
582
+ })
583
+ })
584
+
585
+ Object.values(model.resources).forEach((resource) => {
586
+ if (!systemsById.has(resource.systemPath)) {
273
587
  addIssue(
274
588
  ctx,
275
- ['resources', 'entries', resourceIndex, 'systemId'],
276
- `Resource "${resource.id}" references unknown systemId "${resource.systemId}"`
589
+ ['resources', resource.id, 'systemPath'],
590
+ `Resource "${resource.id}" references unknown system path "${resource.systemPath}"`
277
591
  )
278
592
  }
279
593
 
280
594
  if (resource.ownerRoleId !== undefined && !rolesById.has(resource.ownerRoleId)) {
281
595
  addIssue(
282
596
  ctx,
283
- ['resources', 'entries', resourceIndex, 'ownerRoleId'],
597
+ ['resources', resource.id, 'ownerRoleId'],
284
598
  `Resource "${resource.id}" references unknown ownerRoleId "${resource.ownerRoleId}"`
285
599
  )
286
600
  }
@@ -288,13 +602,13 @@ export const OrganizationModelSchema = OrganizationModelSchemaBase.superRefine((
288
602
  if (resource.kind === 'agent' && resource.actsAsRoleId !== undefined && !rolesById.has(resource.actsAsRoleId)) {
289
603
  addIssue(
290
604
  ctx,
291
- ['resources', 'entries', resourceIndex, 'actsAsRoleId'],
605
+ ['resources', resource.id, 'actsAsRoleId'],
292
606
  `Agent resource "${resource.id}" references unknown actsAsRoleId "${resource.actsAsRoleId}"`
293
607
  )
294
608
  }
295
609
  })
296
610
 
297
- model.roles.roles.forEach((role, roleIndex) => {
611
+ Object.values(model.roles).forEach((role) => {
298
612
  if (role.heldBy === undefined) return
299
613
 
300
614
  asRoleHolderArray(role.heldBy).forEach((holder, holderIndex) => {
@@ -304,7 +618,7 @@ export const OrganizationModelSchema = OrganizationModelSchemaBase.superRefine((
304
618
  if (resource === undefined) {
305
619
  addIssue(
306
620
  ctx,
307
- ['roles', 'roles', roleIndex, 'heldBy', Array.isArray(role.heldBy) ? holderIndex : 'agentId'],
621
+ ['roles', role.id, 'heldBy', Array.isArray(role.heldBy) ? holderIndex : 'agentId'],
308
622
  `Role "${role.id}" references unknown agent holder resource "${holder.agentId}"`
309
623
  )
310
624
  return
@@ -313,22 +627,129 @@ export const OrganizationModelSchema = OrganizationModelSchemaBase.superRefine((
313
627
  if (resource.kind !== 'agent') {
314
628
  addIssue(
315
629
  ctx,
316
- ['roles', 'roles', roleIndex, 'heldBy', Array.isArray(role.heldBy) ? holderIndex : 'agentId'],
630
+ ['roles', role.id, 'heldBy', Array.isArray(role.heldBy) ? holderIndex : 'agentId'],
317
631
  `Role "${role.id}" agent holder "${holder.agentId}" must reference an agent resource`
318
632
  )
319
633
  }
320
634
  })
321
635
  })
322
636
 
323
- model.knowledge.nodes.forEach((node, nodeIndex) => {
637
+ // Phase 4: model.knowledge is now Record<id, OrgKnowledgeNode>
638
+ Object.entries(model.knowledge).forEach(([nodeId, node]) => {
324
639
  node.ownerIds.forEach((roleId, ownerIndex) => {
325
640
  if (!rolesById.has(roleId)) {
326
641
  addIssue(
327
642
  ctx,
328
- ['knowledge', 'nodes', nodeIndex, 'ownerIds', ownerIndex],
643
+ ['knowledge', nodeId, 'ownerIds', ownerIndex],
329
644
  `Knowledge node "${node.id}" references unknown owner role "${roleId}"`
330
645
  )
331
646
  }
332
647
  })
333
648
  })
649
+
650
+ // ---------------------------------------------------------------------------
651
+ // B3, B4, B5, L19 — ContentNode refines (Phase 3, Wave 1A)
652
+ // ---------------------------------------------------------------------------
653
+ //
654
+ // These refines apply recursively to every system in the model tree
655
+ // (top-level systems + nested subsystems at any depth).
656
+ //
657
+ // B3 — Cycle detection: parentContentId chain must not form a cycle.
658
+ // B4 — Same-system-only: parentContentId must resolve within the same content map.
659
+ // B5 — Payload validation: registered (kind, type) pairs validate data against
660
+ // the registered payloadSchema; unregistered pairs pass through (per D2).
661
+ // L19 — Same-meta-kind parent: when both child and parent are registered, they
662
+ // must share the same `kind` (meta-category).
663
+
664
+ type SystemLike = {
665
+ id?: string
666
+ content?: Record<string, { kind: string; type: string; parentContentId?: string; data?: Record<string, unknown> }>
667
+ subsystems?: Record<string, SystemLike>
668
+ }
669
+
670
+ function validateSystemContent(system: SystemLike, systemPath: Array<string | number>): void {
671
+ const content = system.content
672
+ if (content === undefined || Object.keys(content).length === 0) {
673
+ // Recurse into subsystems even when own content is absent.
674
+ if (system.subsystems !== undefined) {
675
+ Object.entries(system.subsystems).forEach(([childKey, child]) => {
676
+ validateSystemContent(child, [...systemPath, 'subsystems', childKey])
677
+ })
678
+ }
679
+ return
680
+ }
681
+
682
+ // B4 — verify every parentContentId resolves within this content map.
683
+ Object.entries(content).forEach(([localId, node]) => {
684
+ if (node.parentContentId !== undefined && !(node.parentContentId in content)) {
685
+ addIssue(
686
+ ctx,
687
+ [...systemPath, 'content', localId, 'parentContentId'],
688
+ `Content node "${localId}" parentContentId "${node.parentContentId}" does not resolve within the same system`
689
+ )
690
+ }
691
+ })
692
+
693
+ // B3 — cycle detection on parentContentId chains within this content map.
694
+ Object.entries(content).forEach(([localId, node]) => {
695
+ const visited = new Set<string>()
696
+ let currentId: string | undefined = node.parentContentId
697
+
698
+ while (currentId !== undefined) {
699
+ if (currentId === localId || visited.has(currentId)) {
700
+ addIssue(
701
+ ctx,
702
+ [...systemPath, 'content', localId, 'parentContentId'],
703
+ `Content node "${localId}" has a parentContentId cycle`
704
+ )
705
+ break
706
+ }
707
+ visited.add(currentId)
708
+ currentId = content[currentId]?.parentContentId
709
+ }
710
+ })
711
+
712
+ // B5 + L19 — per-node payload validation and same-meta-kind parent constraint.
713
+ Object.entries(content).forEach(([localId, node]) => {
714
+ const childDef = lookupContentType(node.kind, node.type)
715
+
716
+ // B5 — validate data against registered payloadSchema when pair is known.
717
+ if (childDef !== undefined && node.data !== undefined) {
718
+ const result = childDef.payloadSchema.safeParse(node.data)
719
+ if (!result.success) {
720
+ addIssue(
721
+ ctx,
722
+ [...systemPath, 'content', localId, 'data'],
723
+ `Content node "${localId}" (${node.kind}:${node.type}) data failed payload validation: ${result.error.message}`
724
+ )
725
+ }
726
+ }
727
+
728
+ // L19 — when both child and parent are registered, they must share the same kind.
729
+ if (node.parentContentId !== undefined && childDef !== undefined) {
730
+ const parentNode = content[node.parentContentId]
731
+ if (parentNode !== undefined) {
732
+ const parentDef = lookupContentType(parentNode.kind, parentNode.type)
733
+ if (parentDef !== undefined && childDef.kind !== parentDef.kind) {
734
+ addIssue(
735
+ ctx,
736
+ [...systemPath, 'content', localId, 'parentContentId'],
737
+ `Content node "${localId}" kind "${childDef.kind}" cannot parent under "${node.parentContentId}" kind "${parentDef.kind}": parentContentId must be same-meta-kind (per L19)`
738
+ )
739
+ }
740
+ }
741
+ }
742
+ })
743
+
744
+ // Recurse into subsystems.
745
+ if (system.subsystems !== undefined) {
746
+ Object.entries(system.subsystems).forEach(([childKey, child]) => {
747
+ validateSystemContent(child, [...systemPath, 'subsystems', childKey])
748
+ })
749
+ }
750
+ }
751
+
752
+ Object.entries(model.systems).forEach(([systemKey, system]) => {
753
+ validateSystemContent(system as SystemLike, ['systems', systemKey])
754
+ })
334
755
  })