@unson/brainbase-mcp 0.32.0 → 0.34.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.
@@ -0,0 +1,681 @@
1
+ // Moved from brainbase-unson server/services/ontology-kernel.js without changing behavior.
2
+ // Only type annotations were added; values stay open JSON objects as in the original.
3
+ /* eslint-disable @typescript-eslint/no-explicit-any */
4
+ const REQUIRED_MANIFEST_KEYS = [
5
+ 'version',
6
+ 'initial_status',
7
+ 'effective_at',
8
+ 'entity_types',
9
+ 'relation_types',
10
+ 'constraints',
11
+ 'inference_rules',
12
+ 'evolution_rules',
13
+ 'previous_version',
14
+ 'compatibility',
15
+ 'migration',
16
+ 'rollback',
17
+ 'governance',
18
+ 'impact_scope',
19
+ 'changes'
20
+ ];
21
+ const REQUIRED_TYPE_FIELDS = ['description', 'identity', 'usage', 'examples', 'counter_examples', 'owner'];
22
+ const REQUIRED_RELATION_FIELDS = ['from', 'to', 'direction', 'cardinality', 'lifecycle', 'provenance'];
23
+ export const DECISION_STATUS_RULE_ID = 'CON-DECISION-STATUS-001';
24
+ export class OntologyError extends Error {
25
+ constructor(code, message, details = {}) {
26
+ super(message);
27
+ this.name = 'OntologyError';
28
+ this.code = code;
29
+ this.details = details;
30
+ }
31
+ }
32
+ function violation(ruleId, message, extra = {}) {
33
+ return { rule_id: ruleId, message, ...extra };
34
+ }
35
+ function matchesWhen(payload, when = {}) {
36
+ return Object.entries(when).every(([key, expected]) => {
37
+ const actual = payload?.[key];
38
+ return Array.isArray(expected) ? expected.includes(actual) : actual === expected;
39
+ });
40
+ }
41
+ function assertManifest(manifest) {
42
+ const missing = REQUIRED_MANIFEST_KEYS.filter((key) => !Object.hasOwn(manifest || {}, key));
43
+ if (missing.length) {
44
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', `Ontology manifest is missing: ${missing.join(', ')}`, { missing });
45
+ }
46
+ if (!Object.keys(manifest.entity_types).length || !Object.keys(manifest.relation_types).length) {
47
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', 'Ontology manifest vocabulary must not be empty');
48
+ }
49
+ const invalidTypes = Object.entries(manifest.entity_types)
50
+ .filter(([, definition]) => REQUIRED_TYPE_FIELDS.some((field) => definition[field] == null))
51
+ .map(([id]) => id);
52
+ const invalidRelations = Object.entries(manifest.relation_types)
53
+ .filter(([, definition]) => REQUIRED_RELATION_FIELDS.some((field) => definition[field] == null)
54
+ || (definition.inverse === undefined && definition.symmetric === undefined))
55
+ .map(([id]) => id);
56
+ if (invalidTypes.length || invalidRelations.length) {
57
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', 'Ontology vocabulary metadata is incomplete', {
58
+ invalid_types: invalidTypes,
59
+ invalid_relations: invalidRelations
60
+ });
61
+ }
62
+ if (!Array.isArray(manifest.changes)
63
+ || !['initial', 'backward_compatible', 'breaking'].includes(manifest.compatibility?.classification)
64
+ || typeof manifest.migration?.required !== 'boolean'
65
+ || typeof manifest.rollback?.strategy !== 'string'
66
+ || !manifest.governance || typeof manifest.governance !== 'object') {
67
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', 'Ontology release governance metadata is incomplete');
68
+ }
69
+ }
70
+ export class OntologyKernel {
71
+ constructor({ manifest, status = null }) {
72
+ assertManifest(manifest);
73
+ this.manifest = structuredClone(manifest);
74
+ this.status = status || manifest.initial_status;
75
+ }
76
+ get version() {
77
+ return this.manifest.version;
78
+ }
79
+ describe() {
80
+ return { ...structuredClone(this.manifest), effective_status: this.status };
81
+ }
82
+ getType(type) {
83
+ const definition = this.manifest.entity_types[type];
84
+ if (!definition) {
85
+ throw new OntologyError('ONTOLOGY_TYPE_UNKNOWN', `Unknown ontology type: ${type}`, { rule_id: 'entity-type-registered', type });
86
+ }
87
+ return structuredClone(definition);
88
+ }
89
+ getRelation(relation) {
90
+ const definition = this.manifest.relation_types[relation];
91
+ if (!definition) {
92
+ throw new OntologyError('ONTOLOGY_RELATION_UNKNOWN', `Unknown ontology relation: ${relation}`, { rule_id: 'relation-type-registered', relation });
93
+ }
94
+ return structuredClone(definition);
95
+ }
96
+ validateEntity(entity, { deferRequiredRelations = false } = {}) {
97
+ const violations = [];
98
+ if (!entity?.id)
99
+ violations.push(violation('entity-id-required', 'Entity id is required'));
100
+ const type = entity?.type || entity?.entity_type;
101
+ if (!this.manifest.entity_types[type]) {
102
+ violations.push(violation('entity-type-registered', `Unknown ontology type: ${type}`, { entity_id: entity?.id, type }));
103
+ }
104
+ // A Decision without an explicit status cannot be evaluated against the
105
+ // conditional relation constraints below. Keep this invariant in the
106
+ // kernel so every release, including older signed manifests, rejects a
107
+ // status omission instead of treating it as an inactive Decision.
108
+ if (type === 'decision') {
109
+ const status = entity?.payload?.status;
110
+ if (typeof status !== 'string' || status.trim() === '') {
111
+ violations.push(violation(DECISION_STATUS_RULE_ID, 'Decision requires an explicit status', { entity_id: entity?.id, missing_fields: ['status'] }));
112
+ }
113
+ }
114
+ for (const rule of this.manifest.constraints) {
115
+ if (rule.target !== type || rule.kind !== 'required_fields_when')
116
+ continue;
117
+ const matches = matchesWhen(entity?.payload, rule.when);
118
+ if (!matches)
119
+ continue;
120
+ const missingFields = rule.fields.filter((field) => {
121
+ const value = entity?.payload?.[field];
122
+ return value == null || value === '' || (Array.isArray(value) && value.length === 0);
123
+ });
124
+ if (missingFields.length) {
125
+ violations.push(violation(rule.id, `Missing required fields: ${missingFields.join(', ')}`, {
126
+ entity_id: entity?.id,
127
+ missing_fields: missingFields
128
+ }));
129
+ }
130
+ }
131
+ if (!deferRequiredRelations) {
132
+ for (const rule of this.manifest.constraints) {
133
+ if (rule.target !== type || !['required_relation', 'required_relation_when'].includes(rule.kind))
134
+ continue;
135
+ if (rule.kind === 'required_relation_when' && !matchesWhen(entity?.payload, rule.when))
136
+ continue;
137
+ violations.push(violation(rule.id, rule.message || `${type} requires relation evidence in an aggregate snapshot`, {
138
+ entity_id: entity?.id,
139
+ aggregate_required: true
140
+ }));
141
+ }
142
+ }
143
+ return { valid: violations.length === 0, ontology_version: this.version, violations };
144
+ }
145
+ validateEdge(edge) {
146
+ const relation = edge?.relation || edge?.rel_type;
147
+ const definition = this.manifest.relation_types[relation];
148
+ if (!definition) {
149
+ return {
150
+ valid: false,
151
+ ontology_version: this.version,
152
+ violations: [violation('relation-type-registered', `Unknown ontology relation: ${relation}`, { relation })]
153
+ };
154
+ }
155
+ const fromType = edge?.from_type;
156
+ const toType = edge?.to_type;
157
+ const validEndpoint = definition.from.includes(fromType) && definition.to.includes(toType);
158
+ return {
159
+ valid: validEndpoint,
160
+ ontology_version: this.version,
161
+ violations: validEndpoint ? [] : [violation(`relation-endpoint-${relation}`, `Invalid ${relation} endpoint`, {
162
+ relation,
163
+ from_type: fromType,
164
+ to_type: toType
165
+ })]
166
+ };
167
+ }
168
+ /**
169
+ * Which persisted edges validateSnapshot needs in order to judge a
170
+ * mutation. Only two rules read existing edges: relation cardinality
171
+ * (same relation, same constrained endpoint as a mutation edge) and
172
+ * required relations (the listed relations, in their direction, from a
173
+ * validated entity of the rule's target type). Every other persisted
174
+ * edge cannot change the verdict for this mutation.
175
+ *
176
+ * @returns {{ relation: string, endpoint: 'from_id'|'to_id', ids: string[] }[]}
177
+ */
178
+ mutationEdgeScope({ mutationEdges = [], validationEntities = [] } = {}) {
179
+ const groups = new Map();
180
+ const add = (relation, endpoint, id) => {
181
+ if (!relation || !id)
182
+ return;
183
+ const key = `${relation}\u0000${endpoint}`;
184
+ if (!groups.has(key))
185
+ groups.set(key, { relation, endpoint, ids: new Set() });
186
+ groups.get(key).ids.add(id);
187
+ };
188
+ for (const edge of mutationEdges) {
189
+ const relation = edge?.relation || edge?.rel_type;
190
+ const cardinality = this.manifest.relation_types[relation]?.cardinality;
191
+ if (cardinality === 'many_to_one' || cardinality === 'one_to_one')
192
+ add(relation, 'from_id', edge.from_id);
193
+ if (cardinality === 'one_to_many' || cardinality === 'one_to_one')
194
+ add(relation, 'to_id', edge.to_id);
195
+ }
196
+ for (const rule of this.manifest.constraints) {
197
+ if (!['required_relation', 'required_relation_when'].includes(rule.kind))
198
+ continue;
199
+ const alternatives = rule.alternatives || [{ relation: rule.relation, direction: 'outgoing' }];
200
+ for (const entity of validationEntities) {
201
+ if ((entity?.type || entity?.entity_type) !== rule.target)
202
+ continue;
203
+ for (const alternative of alternatives) {
204
+ add(alternative.relation, alternative.direction === 'incoming' ? 'to_id' : 'from_id', entity.id);
205
+ }
206
+ }
207
+ }
208
+ return Array.from(groups.values(), (group) => ({ ...group, ids: Array.from(group.ids).sort() }));
209
+ }
210
+ validateSnapshot(snapshot, _adapters = {}) {
211
+ const entities = snapshot?.entities || [];
212
+ const edges = snapshot?.edges || [];
213
+ const byId = new Map(entities.map((entity) => [entity.id, entity]));
214
+ const validationEntityIds = Array.isArray(snapshot?.validation_entity_ids)
215
+ ? new Set(snapshot.validation_entity_ids)
216
+ : null;
217
+ const validationEntities = validationEntityIds
218
+ ? entities.filter((entity) => validationEntityIds.has(entity.id))
219
+ : entities;
220
+ const requiredRelationValidationEntityIds = Array.isArray(snapshot?.required_relation_validation_entity_ids)
221
+ ? new Set(snapshot.required_relation_validation_entity_ids)
222
+ : validationEntityIds;
223
+ const requiredRelationValidationEntities = requiredRelationValidationEntityIds
224
+ ? entities.filter((entity) => requiredRelationValidationEntityIds.has(entity.id))
225
+ : entities;
226
+ const violations = validationEntities.flatMap((entity) => this.validateEntity(entity, { deferRequiredRelations: true }).violations);
227
+ for (const edge of edges) {
228
+ const from = byId.get(edge.from_id);
229
+ const to = byId.get(edge.to_id);
230
+ if (!from || !to) {
231
+ violations.push(violation('edge-reference-integrity', 'Edge endpoints must reference entities in the snapshot', {
232
+ edge_id: edge.id || null,
233
+ missing_endpoint_ids: [!from ? edge.from_id : null, !to ? edge.to_id : null].filter(Boolean)
234
+ }));
235
+ continue;
236
+ }
237
+ violations.push(...this.validateEdge({ ...edge, from_type: edge.from_type || from?.type, to_type: edge.to_type || to?.type }).violations);
238
+ }
239
+ for (const [relation, definition] of Object.entries(this.manifest.relation_types)) {
240
+ if (!['many_to_one', 'one_to_many', 'one_to_one'].includes(definition.cardinality))
241
+ continue;
242
+ const relationEdges = edges.filter((edge) => (edge.relation || edge.rel_type) === relation);
243
+ const constrainedSides = definition.cardinality === 'many_to_one'
244
+ ? [['from_id', 'outgoing']]
245
+ : definition.cardinality === 'one_to_many'
246
+ ? [['to_id', 'incoming']]
247
+ : [['from_id', 'outgoing'], ['to_id', 'incoming']];
248
+ for (const [endpoint, direction] of constrainedSides) {
249
+ const counts = new Map();
250
+ for (const edge of relationEdges)
251
+ counts.set(edge[endpoint], (counts.get(edge[endpoint]) || 0) + 1);
252
+ for (const [entityId, count] of counts) {
253
+ if (count <= 1)
254
+ continue;
255
+ violations.push(violation(`relation-cardinality-${relation}`, `${relation} permits at most one ${direction} edge`, {
256
+ relation,
257
+ entity_id: entityId,
258
+ direction,
259
+ count,
260
+ cardinality: definition.cardinality
261
+ }));
262
+ }
263
+ }
264
+ }
265
+ for (const rule of this.manifest.constraints) {
266
+ if (!['required_relation', 'required_relation_when'].includes(rule.kind))
267
+ continue;
268
+ for (const entity of requiredRelationValidationEntities.filter((item) => (item.type || item.entity_type) === rule.target)) {
269
+ if (rule.kind === 'required_relation_when' && !matchesWhen(entity.payload, rule.when))
270
+ continue;
271
+ const alternatives = rule.alternatives || [{ relation: rule.relation, direction: 'outgoing' }];
272
+ const hasRelation = alternatives.some((alternative) => edges.some((edge) => {
273
+ const endpointId = alternative.direction === 'incoming' ? edge.to_id : edge.from_id;
274
+ if (endpointId !== entity.id || (edge.relation || edge.rel_type) !== alternative.relation)
275
+ return false;
276
+ if (!Array.isArray(rule.related_types) || rule.related_types.length === 0)
277
+ return true;
278
+ const relatedId = alternative.direction === 'incoming' ? edge.from_id : edge.to_id;
279
+ const related = byId.get(relatedId);
280
+ return related && rule.related_types.includes(related.type || related.entity_type);
281
+ }));
282
+ if (!hasRelation) {
283
+ violations.push(violation(rule.id, rule.message || `${rule.target} requires relation evidence`, { entity_id: entity.id }));
284
+ }
285
+ }
286
+ }
287
+ if (snapshot?.complete === false) {
288
+ violations.push(violation('snapshot-incomplete', 'Snapshot is incomplete; absence cannot be verified'));
289
+ }
290
+ return {
291
+ valid: violations.length === 0,
292
+ verification: snapshot?.complete === false ? 'unverified' : 'verified',
293
+ ontology_version: this.version,
294
+ violations
295
+ };
296
+ }
297
+ inferDecisions(snapshot, { derivedAt } = {}) {
298
+ const asOf = snapshot?.as_of || new Date().toISOString();
299
+ const derived_at = derivedAt || snapshot?.derived_at || new Date().toISOString();
300
+ const decisions = Object.fromEntries((snapshot?.entities || [])
301
+ .filter((entity) => (entity.type || entity.entity_type) === 'decision')
302
+ .map((entity) => [entity.id, { ...entity.payload, explicit: true, inferred: false }]));
303
+ const evidence = [];
304
+ const supersessionRule = this.manifest.inference_rules.find((rule) => rule.relation === 'supersedes');
305
+ const effectiveStatuses = new Set(supersessionRule?.effective_statuses || ['active']);
306
+ const isEffectiveSupersession = (edge) => {
307
+ if ((edge.relation || edge.rel_type) !== 'supersedes')
308
+ return false;
309
+ const replacement = decisions[edge.from_id];
310
+ if (!replacement || !decisions[edge.to_id] || !effectiveStatuses.has(replacement.status))
311
+ return false;
312
+ const asOfTime = Date.parse(asOf);
313
+ return [edge.effective_at, replacement.effective_at]
314
+ .filter(Boolean)
315
+ .every((effectiveAt) => Date.parse(effectiveAt) <= asOfTime);
316
+ };
317
+ for (const edge of snapshot?.edges || []) {
318
+ if (!isEffectiveSupersession(edge))
319
+ continue;
320
+ const replacement = decisions[edge.from_id];
321
+ decisions[edge.to_id] = { ...decisions[edge.to_id], status: 'superseded', inferred: true };
322
+ evidence.push({ rule_id: supersessionRule?.id || 'decision-supersession', from_id: edge.from_id, to_id: edge.to_id });
323
+ }
324
+ const explicitSupersessions = new Set((snapshot?.edges || [])
325
+ .filter(isEffectiveSupersession)
326
+ .flatMap((edge) => [`${edge.from_id}:${edge.to_id}`, `${edge.to_id}:${edge.from_id}`]));
327
+ const scopesByDecision = new Map(Object.entries(decisions).map(([id, decision]) => [
328
+ id,
329
+ new Set(Array.isArray(decision.scope_ids) ? decision.scope_ids : [])
330
+ ]));
331
+ const entitiesById = new Map((snapshot?.entities || []).map((entity) => [entity.id, entity]));
332
+ for (const edge of snapshot?.edges || []) {
333
+ if ((edge.relation || edge.rel_type) !== 'belongs_to_project' || !scopesByDecision.has(edge.from_id))
334
+ continue;
335
+ const scope = entitiesById.get(edge.to_id);
336
+ if (!scope || (scope.type || scope.entity_type) !== 'project')
337
+ continue;
338
+ scopesByDecision.get(edge.from_id).add(edge.to_id);
339
+ }
340
+ const activeIds = Object.entries(decisions)
341
+ .filter(([, decision]) => effectiveStatuses.has(decision.status))
342
+ .map(([id]) => id);
343
+ for (let leftIndex = 0; leftIndex < activeIds.length; leftIndex += 1) {
344
+ for (let rightIndex = leftIndex + 1; rightIndex < activeIds.length; rightIndex += 1) {
345
+ const leftId = activeIds[leftIndex];
346
+ const rightId = activeIds[rightIndex];
347
+ const leftScopes = scopesByDecision.get(leftId) || new Set();
348
+ const rightScopes = scopesByDecision.get(rightId) || new Set();
349
+ const overlaps = [...leftScopes].some((scope) => rightScopes.has(scope));
350
+ if (!overlaps || explicitSupersessions.has(`${leftId}:${rightId}`))
351
+ continue;
352
+ decisions[leftId] = { ...decisions[leftId], status: 'conflict', inferred: true };
353
+ decisions[rightId] = { ...decisions[rightId], status: 'conflict', inferred: true };
354
+ evidence.push({ rule_id: 'decision-active-conflict', decision_ids: [leftId, rightId] });
355
+ }
356
+ }
357
+ const explanation = evidence.length
358
+ ? evidence.map((item) => item.decision_ids
359
+ ? `${item.decision_ids.join(' and ')} conflict`
360
+ : `${item.from_id} supersedes ${item.to_id}`).join('; ')
361
+ : 'No decision supersession was inferred';
362
+ return { ontology_version: this.version, as_of: asOf, derived_at, decisions, evidence, explanation };
363
+ }
364
+ impact({ change = {}, snapshot = null }) {
365
+ const kind = change.kind || 'editorial';
366
+ const semver = kind.startsWith('remove_') || kind === 'narrow_endpoint'
367
+ ? this.manifest.evolution_rules.breaking
368
+ : kind.startsWith('add_')
369
+ ? this.manifest.evolution_rules.additive
370
+ : this.manifest.evolution_rules.editorial;
371
+ if (!snapshot) {
372
+ return {
373
+ ontology_version: this.version,
374
+ semver,
375
+ verification: 'unverified',
376
+ match_count: null,
377
+ representative_ids: [],
378
+ affected_apis: change.affected_apis || [],
379
+ affected_agents: change.affected_agents || [],
380
+ migration_required: semver === 'major',
381
+ history_required: this.manifest.evolution_rules.history_required_for.includes(kind)
382
+ };
383
+ }
384
+ const entityIds = (snapshot.entities || [])
385
+ .filter((entity) => !change.type || (entity.type || entity.entity_type) === change.type)
386
+ .map((entity) => entity.id);
387
+ const edgeIds = (snapshot.edges || [])
388
+ .filter((edge) => !change.relation || (edge.relation || edge.rel_type) === change.relation)
389
+ .map((edge) => edge.id || `${edge.from_id}:${edge.relation || edge.rel_type}:${edge.to_id}`);
390
+ let ids = change.relation ? edgeIds : entityIds;
391
+ if (change.rule_id) {
392
+ const rule = [...this.manifest.constraints, ...this.manifest.inference_rules]
393
+ .find((candidate) => candidate.id === change.rule_id);
394
+ if (!rule) {
395
+ throw new OntologyError('ONTOLOGY_RULE_UNKNOWN', `Unknown ontology rule: ${change.rule_id}`);
396
+ }
397
+ ids = rule.relation
398
+ ? (snapshot.edges || []).filter((edge) => (edge.relation || edge.rel_type) === rule.relation)
399
+ .map((edge) => edge.id || `${edge.from_id}:${edge.relation || edge.rel_type}:${edge.to_id}`)
400
+ : (snapshot.entities || []).filter((entity) => !rule.target || (entity.type || entity.entity_type) === rule.target)
401
+ .map((entity) => entity.id);
402
+ }
403
+ return {
404
+ ontology_version: this.version,
405
+ semver,
406
+ verification: 'verified',
407
+ match_count: ids.length,
408
+ representative_ids: ids.slice(0, 10),
409
+ affected_apis: change.affected_apis || [],
410
+ affected_agents: change.affected_agents || [],
411
+ migration_required: semver === 'major',
412
+ history_required: this.manifest.evolution_rules.history_required_for.includes(kind)
413
+ };
414
+ }
415
+ planEvolution(change = {}) {
416
+ const kind = change.kind;
417
+ if (!this.manifest.evolution_rules.history_required_for.includes(kind)) {
418
+ throw new OntologyError('ONTOLOGY_EVOLUTION_KIND_INVALID', `Evolution kind does not require identity history: ${kind}`);
419
+ }
420
+ const required = ['canonical_id', 'source_ids', 'effective_at'];
421
+ const missing = required.filter((field) => !change[field]
422
+ || (field === 'source_ids' && (!Array.isArray(change[field]) || change[field].length === 0)));
423
+ if (missing.length) {
424
+ throw new OntologyError('ONTOLOGY_EVOLUTION_HISTORY_REQUIRED', `Evolution history is missing: ${missing.join(', ')}`, { missing });
425
+ }
426
+ return {
427
+ event_id: change.event_id || `ontology:${kind}:${change.canonical_id}:${change.effective_at}`,
428
+ event_type: `ontology_${kind}`,
429
+ ontology_version: this.version,
430
+ canonical_id: change.canonical_id,
431
+ source_ids: [...new Set(change.source_ids)],
432
+ aliases: kind === 'rename' ? [...new Set(change.source_ids)] : [],
433
+ provenance: change.provenance || [],
434
+ effective_at: change.effective_at,
435
+ conflict_policy: change.conflict_policy || 'explicit_decision_required'
436
+ };
437
+ }
438
+ interpretHistory(snapshot = {}, { asOf } = {}) {
439
+ const interpretationTime = asOf || snapshot.as_of;
440
+ if (!interpretationTime || !Number.isFinite(Date.parse(interpretationTime))) {
441
+ throw new OntologyError('ONTOLOGY_HISTORY_AS_OF_REQUIRED', 'A valid as_of timestamp is required for historical interpretation');
442
+ }
443
+ const events = (snapshot.evolution_events || [])
444
+ .filter((event) => Date.parse(event.effective_at) <= Date.parse(interpretationTime))
445
+ .sort((left, right) => Date.parse(left.effective_at) - Date.parse(right.effective_at));
446
+ const canonicalById = new Map();
447
+ const provenanceById = new Map();
448
+ for (const event of events) {
449
+ for (const sourceId of event.source_ids || []) {
450
+ if (canonicalById.has(sourceId) && canonicalById.get(sourceId) !== event.canonical_id) {
451
+ throw new OntologyError('ONTOLOGY_EVOLUTION_CONFLICT', `Multiple active evolution targets exist for ${sourceId}`, {
452
+ source_id: sourceId,
453
+ canonical_ids: [canonicalById.get(sourceId), event.canonical_id]
454
+ });
455
+ }
456
+ canonicalById.set(sourceId, event.canonical_id);
457
+ provenanceById.set(sourceId, [...(event.provenance || [])]);
458
+ }
459
+ }
460
+ const resolveId = (id) => {
461
+ const seen = new Set();
462
+ let current = id;
463
+ const provenance = [];
464
+ while (canonicalById.has(current)) {
465
+ if (seen.has(current)) {
466
+ throw new OntologyError('ONTOLOGY_EVOLUTION_CYCLE', `Evolution history contains a cycle from ${id}`, {
467
+ source_id: id,
468
+ cycle_at: current
469
+ });
470
+ }
471
+ seen.add(current);
472
+ provenance.push(...(provenanceById.get(current) || []));
473
+ current = canonicalById.get(current);
474
+ }
475
+ return { canonical_id: current, provenance: [...new Set(provenance)] };
476
+ };
477
+ return {
478
+ ontology_version: this.version,
479
+ as_of: interpretationTime,
480
+ applied_event_ids: events.map((event) => event.event_id),
481
+ entities: (snapshot.entities || []).map((entity) => {
482
+ const interpretation = resolveId(entity.id);
483
+ return {
484
+ ...structuredClone(entity),
485
+ historical_id: entity.id,
486
+ canonical_id: interpretation.canonical_id,
487
+ evolution_provenance: interpretation.provenance
488
+ };
489
+ }),
490
+ edges: (snapshot.edges || []).map((edge) => {
491
+ const from = resolveId(edge.from_id);
492
+ const to = resolveId(edge.to_id);
493
+ return {
494
+ ...structuredClone(edge),
495
+ historical_from_id: edge.from_id,
496
+ historical_to_id: edge.to_id,
497
+ from_id: from.canonical_id,
498
+ to_id: to.canonical_id,
499
+ evolution_provenance: [...new Set([...from.provenance, ...to.provenance])]
500
+ };
501
+ })
502
+ };
503
+ }
504
+ }
505
+ const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
506
+ const BUMP_RANK = { patch: 1, minor: 2, major: 3 };
507
+ const CARDINALITY_ORDER = ['one_to_one', 'many_to_one', 'one_to_many', 'many_to_many'];
508
+ function parseSemver(version) {
509
+ const match = SEMVER_PATTERN.exec(String(version || ''));
510
+ return match ? match.slice(1).map(Number) : null;
511
+ }
512
+ function declaredBump(baseVersion, nextVersion) {
513
+ const base = parseSemver(baseVersion);
514
+ const next = parseSemver(nextVersion);
515
+ if (!base || !next)
516
+ return null;
517
+ if (next[0] !== base[0])
518
+ return next[0] > base[0] && next[1] === 0 && next[2] === 0 ? 'major' : null;
519
+ if (next[1] !== base[1])
520
+ return next[1] > base[1] && next[2] === 0 ? 'minor' : null;
521
+ return next[2] > base[2] ? 'patch' : null;
522
+ }
523
+ function plainObject(value) {
524
+ return value && typeof value === 'object' && !Array.isArray(value);
525
+ }
526
+ function relationNarrows(before, after) {
527
+ const lost = (key) => (before[key] || []).some((type) => !(after[key] || []).includes(type));
528
+ const cardinalityTightened = CARDINALITY_ORDER.includes(before.cardinality)
529
+ && CARDINALITY_ORDER.includes(after.cardinality)
530
+ && CARDINALITY_ORDER.indexOf(after.cardinality) < CARDINALITY_ORDER.indexOf(before.cardinality);
531
+ return lost('from') || lost('to') || cardinalityTightened;
532
+ }
533
+ function applyVocabularyChanges(target, changeSet, kind, classify) {
534
+ const upsert = changeSet?.upsert ?? {};
535
+ const remove = changeSet?.remove ?? [];
536
+ if (!plainObject(upsert) || !Array.isArray(remove)) {
537
+ throw new OntologyError('ONTOLOGY_PROPOSAL_INVALID', `${kind} changes must be { upsert: object, remove: string[] }`, { http_status: 400 });
538
+ }
539
+ for (const id of remove) {
540
+ if (!Object.hasOwn(target, id)) {
541
+ throw new OntologyError('ONTOLOGY_PROPOSAL_INVALID', `Cannot remove unknown ${kind}: ${id}`, { http_status: 400 });
542
+ }
543
+ delete target[id];
544
+ classify('major', `remove ${kind} ${id}`);
545
+ }
546
+ for (const [id, definition] of Object.entries(upsert)) {
547
+ if (!plainObject(definition)) {
548
+ throw new OntologyError('ONTOLOGY_PROPOSAL_INVALID', `${kind} ${id} definition must be an object`, { http_status: 400 });
549
+ }
550
+ const previous = target[id];
551
+ target[id] = structuredClone(definition);
552
+ if (!previous)
553
+ classify('minor', `add ${kind} ${id}`);
554
+ else if (kind === 'relation' && relationNarrows(previous, definition))
555
+ classify('major', `narrow relation ${id}`);
556
+ else if (kind === 'relation' && (['from', 'to'].some((key) => (definition[key] || []).length > (previous[key] || []).length))) {
557
+ classify('minor', `widen relation ${id}`);
558
+ }
559
+ else
560
+ classify('patch', `update ${kind} ${id}`);
561
+ }
562
+ }
563
+ function applyRuleChanges(rules, changeSet, kind, classify) {
564
+ const upsert = changeSet?.upsert ?? [];
565
+ const remove = changeSet?.remove ?? [];
566
+ if (!Array.isArray(upsert) || !Array.isArray(remove)) {
567
+ throw new OntologyError('ONTOLOGY_PROPOSAL_INVALID', `${kind} changes must be { upsert: object[], remove: string[] }`, { http_status: 400 });
568
+ }
569
+ let next = rules.map((rule) => structuredClone(rule));
570
+ for (const id of remove) {
571
+ if (!next.some((rule) => rule.id === id)) {
572
+ throw new OntologyError('ONTOLOGY_PROPOSAL_INVALID', `Cannot remove unknown ${kind}: ${id}`, { http_status: 400 });
573
+ }
574
+ next = next.filter((rule) => rule.id !== id);
575
+ classify('minor', `remove ${kind} ${id}`);
576
+ }
577
+ for (const rule of upsert) {
578
+ if (!plainObject(rule) || typeof rule.id !== 'string' || !rule.id.trim()) {
579
+ throw new OntologyError('ONTOLOGY_PROPOSAL_INVALID', `${kind} entries require a string id`, { http_status: 400 });
580
+ }
581
+ const index = next.findIndex((candidate) => candidate.id === rule.id);
582
+ // A new or changed rule can reject existing data, so it is classified
583
+ // as additive here and the data impact decides whether migration is needed.
584
+ classify('minor', `${index === -1 ? 'add' : 'update'} ${kind} ${rule.id}`);
585
+ if (index === -1)
586
+ next.push(structuredClone(rule));
587
+ else
588
+ next[index] = structuredClone(rule);
589
+ }
590
+ return next;
591
+ }
592
+ function consistencyViolations(manifest) {
593
+ const types = new Set(Object.keys(manifest.entity_types));
594
+ const relations = new Set(Object.keys(manifest.relation_types));
595
+ const violations = [];
596
+ for (const [id, definition] of Object.entries(manifest.relation_types)) {
597
+ for (const side of ['from', 'to']) {
598
+ for (const type of definition[side] || []) {
599
+ if (!types.has(type))
600
+ violations.push(violation('relation-endpoint-type-registered', `Relation ${id} ${side} references unknown type ${type}`, { relation: id, type }));
601
+ }
602
+ }
603
+ if (definition.inverse && !relations.has(definition.inverse)) {
604
+ violations.push(violation('relation-inverse-registered', `Relation ${id} inverse ${definition.inverse} is not registered`, { relation: id }));
605
+ }
606
+ }
607
+ for (const rule of [...manifest.constraints, ...manifest.inference_rules]) {
608
+ if (rule.target && !types.has(rule.target)) {
609
+ violations.push(violation('rule-target-registered', `Rule ${rule.id} targets unknown type ${rule.target}`, { rule_id_ref: rule.id }));
610
+ }
611
+ for (const type of rule.related_types || []) {
612
+ if (!types.has(type))
613
+ violations.push(violation('rule-related-type-registered', `Rule ${rule.id} references unknown type ${type}`, { rule_id_ref: rule.id }));
614
+ }
615
+ for (const relation of [rule.relation, ...(rule.alternatives || []).map((item) => item.relation)].filter(Boolean)) {
616
+ if (!relations.has(relation))
617
+ violations.push(violation('rule-relation-registered', `Rule ${rule.id} references unknown relation ${relation}`, { rule_id_ref: rule.id }));
618
+ }
619
+ }
620
+ const ruleIds = [...manifest.constraints, ...manifest.inference_rules].map((rule) => rule.id);
621
+ for (const id of ruleIds.filter((ruleId, index) => ruleIds.indexOf(ruleId) !== index)) {
622
+ violations.push(violation('rule-id-unique', `Rule id ${id} is duplicated`, { rule_id_ref: id }));
623
+ }
624
+ return violations;
625
+ }
626
+ /**
627
+ * Build an immutable Ontology release candidate from a base release and a
628
+ * change set. The result is a dry-run artifact: it is never written here and
629
+ * becomes current only through the RACI-authorized publisher (ADR-021).
630
+ */
631
+ export function buildOntologyReleaseCandidate(baseManifest, proposal = {}) {
632
+ const nextVersion = String(proposal.next_version || '').trim();
633
+ const summary = Array.isArray(proposal.summary)
634
+ ? proposal.summary.filter((line) => typeof line === 'string' && line.trim()).map((line) => line.trim())
635
+ : [];
636
+ if (!parseSemver(nextVersion)) {
637
+ throw new OntologyError('ONTOLOGY_PROPOSAL_INVALID', 'next_version must be a semantic version', { http_status: 400 });
638
+ }
639
+ if (!summary.length) {
640
+ throw new OntologyError('ONTOLOGY_PROPOSAL_INVALID', 'summary must describe the change in at least one line', { http_status: 400 });
641
+ }
642
+ const changes = plainObject(proposal.changes) ? proposal.changes : {};
643
+ const reasons = [];
644
+ let requiredBump = 'patch';
645
+ const classify = (bump, reason) => {
646
+ reasons.push({ bump, reason });
647
+ if (BUMP_RANK[bump] > BUMP_RANK[requiredBump])
648
+ requiredBump = bump;
649
+ };
650
+ const candidate = structuredClone(baseManifest);
651
+ applyVocabularyChanges(candidate.entity_types, changes.entity_types, 'type', classify);
652
+ applyVocabularyChanges(candidate.relation_types, changes.relation_types, 'relation', classify);
653
+ candidate.constraints = applyRuleChanges(candidate.constraints, changes.constraints, 'constraint', classify);
654
+ candidate.inference_rules = applyRuleChanges(candidate.inference_rules, changes.inference_rules, 'inference_rule', classify);
655
+ if (!reasons.length) {
656
+ throw new OntologyError('ONTOLOGY_PROPOSAL_INVALID', 'The change set does not change the release', { http_status: 400 });
657
+ }
658
+ const breaking = requiredBump === 'major';
659
+ candidate.version = nextVersion;
660
+ candidate.previous_version = baseManifest.version;
661
+ candidate.initial_status = 'proposed';
662
+ candidate.effective_at = typeof proposal.effective_at === 'string' && proposal.effective_at.trim()
663
+ ? proposal.effective_at.trim()
664
+ : new Date().toISOString();
665
+ candidate.compatibility = breaking
666
+ ? { classification: 'breaking' }
667
+ : { classification: 'backward_compatible', compatible_from: baseManifest.version };
668
+ candidate.migration = { required: breaking, plan: typeof proposal.migration_plan === 'string' ? proposal.migration_plan : null };
669
+ candidate.rollback = { strategy: 'restore_previous_current', target_version: baseManifest.version };
670
+ candidate.governance = plainObject(proposal.governance) ? structuredClone(proposal.governance) : {};
671
+ candidate.changes = summary;
672
+ const declared = declaredBump(baseManifest.version, nextVersion);
673
+ return {
674
+ candidate,
675
+ required_bump: requiredBump,
676
+ declared_bump: declared,
677
+ semver_ok: declared !== null && BUMP_RANK[declared] >= BUMP_RANK[requiredBump],
678
+ change_classification: reasons,
679
+ consistency_violations: consistencyViolations(candidate)
680
+ };
681
+ }