@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.
- package/dist/graph-maintenance-engine.d.ts +44 -0
- package/dist/graph-maintenance-engine.js +804 -0
- package/dist/memory-scope-policy.d.ts +43 -0
- package/dist/memory-scope-policy.js +159 -0
- package/dist/ontology-kernel.d.ts +102 -0
- package/dist/ontology-kernel.js +681 -0
- package/dist/ontology-publication.d.ts +6 -0
- package/dist/ontology-publication.js +44 -0
- package/dist/ontology-registry.d.ts +32 -0
- package/dist/ontology-registry.js +202 -0
- package/dist/ontology-release-trust.d.ts +4 -0
- package/dist/ontology-release-trust.js +121 -0
- package/package.json +26 -2
- package/ui/world/world-view.js +24 -0
|
@@ -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
|
+
}
|