@unson/brainbase-mcp 0.33.0 → 0.35.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,102 @@
1
+ export declare const DECISION_STATUS_RULE_ID = "CON-DECISION-STATUS-001";
2
+ export declare class OntologyError extends Error {
3
+ [key: string]: any;
4
+ constructor(code: any, message: any, details?: any);
5
+ }
6
+ export declare class OntologyKernel {
7
+ [key: string]: any;
8
+ constructor({ manifest, status }: any);
9
+ get version(): any;
10
+ describe(): any;
11
+ getType(type: any): any;
12
+ getRelation(relation: any): any;
13
+ validateEntity(entity: any, { deferRequiredRelations }?: any): {
14
+ valid: boolean;
15
+ ontology_version: any;
16
+ violations: any[];
17
+ };
18
+ validateEdge(edge: any): {
19
+ valid: any;
20
+ ontology_version: any;
21
+ violations: any[];
22
+ };
23
+ /**
24
+ * Which persisted edges validateSnapshot needs in order to judge a
25
+ * mutation. Only two rules read existing edges: relation cardinality
26
+ * (same relation, same constrained endpoint as a mutation edge) and
27
+ * required relations (the listed relations, in their direction, from a
28
+ * validated entity of the rule's target type). Every other persisted
29
+ * edge cannot change the verdict for this mutation.
30
+ *
31
+ * @returns {{ relation: string, endpoint: 'from_id'|'to_id', ids: string[] }[]}
32
+ */
33
+ mutationEdgeScope({ mutationEdges, validationEntities }?: any): any[];
34
+ validateSnapshot(snapshot: any, _adapters?: any): {
35
+ valid: boolean;
36
+ verification: string;
37
+ ontology_version: any;
38
+ violations: any;
39
+ };
40
+ inferDecisions(snapshot: any, { derivedAt }?: any): {
41
+ ontology_version: any;
42
+ as_of: any;
43
+ derived_at: any;
44
+ decisions: {
45
+ [k: string]: any;
46
+ };
47
+ evidence: ({
48
+ rule_id: any;
49
+ from_id: any;
50
+ to_id: any;
51
+ decision_ids?: undefined;
52
+ } | {
53
+ rule_id: string;
54
+ decision_ids: any[];
55
+ from_id?: undefined;
56
+ to_id?: undefined;
57
+ })[];
58
+ explanation: string;
59
+ };
60
+ impact({ change, snapshot }: any): {
61
+ ontology_version: any;
62
+ semver: any;
63
+ verification: string;
64
+ match_count: any;
65
+ representative_ids: any;
66
+ affected_apis: any;
67
+ affected_agents: any;
68
+ migration_required: boolean;
69
+ history_required: any;
70
+ };
71
+ planEvolution(change?: any): {
72
+ event_id: any;
73
+ event_type: string;
74
+ ontology_version: any;
75
+ canonical_id: any;
76
+ source_ids: unknown[];
77
+ aliases: unknown[];
78
+ provenance: any;
79
+ effective_at: any;
80
+ conflict_policy: any;
81
+ };
82
+ interpretHistory(snapshot?: any, { asOf }?: any): {
83
+ ontology_version: any;
84
+ as_of: any;
85
+ applied_event_ids: any;
86
+ entities: any;
87
+ edges: any;
88
+ };
89
+ }
90
+ /**
91
+ * Build an immutable Ontology release candidate from a base release and a
92
+ * change set. The result is a dry-run artifact: it is never written here and
93
+ * becomes current only through the RACI-authorized publisher (ADR-021).
94
+ */
95
+ export declare function buildOntologyReleaseCandidate(baseManifest: any, proposal?: any): {
96
+ candidate: any;
97
+ required_bump: string;
98
+ declared_bump: string | null;
99
+ semver_ok: boolean;
100
+ change_classification: any[];
101
+ consistency_violations: any[];
102
+ };
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ export declare const ONTOLOGY_PUBLICATION_RECEIPT_SCHEMA_VERSION = "1.0.0";
2
+ export declare function sha256(bytes: any): string;
3
+ export declare function canonicalize(value: any): any;
4
+ export declare function canonicalJson(value: any): string;
5
+ export declare function publicationReceiptContractErrors(receipt: any): string[];
6
+ export declare function verifyPublicationReceipt(receipt: any, publicKeyPem: any): boolean;
@@ -0,0 +1,44 @@
1
+ // Moved from brainbase-unson server/services/ontology-publication.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
+ import { createHash, verify } from 'node:crypto';
5
+ export const ONTOLOGY_PUBLICATION_RECEIPT_SCHEMA_VERSION = '1.0.0';
6
+ export function sha256(bytes) {
7
+ return createHash('sha256').update(bytes).digest('hex');
8
+ }
9
+ export function canonicalize(value) {
10
+ if (Array.isArray(value))
11
+ return value.map(canonicalize);
12
+ if (value && typeof value === 'object') {
13
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
14
+ }
15
+ return value;
16
+ }
17
+ export function canonicalJson(value) {
18
+ return JSON.stringify(canonicalize(value));
19
+ }
20
+ export function publicationReceiptContractErrors(receipt) {
21
+ const errors = [];
22
+ if (receipt?.payload?.schema_version !== ONTOLOGY_PUBLICATION_RECEIPT_SCHEMA_VERSION) {
23
+ errors.push('schema_version');
24
+ }
25
+ if (typeof receipt?.payload?.actor_entity_id !== 'string' || !receipt.payload.actor_entity_id.trim()) {
26
+ errors.push('actor_entity_id');
27
+ }
28
+ const issuedAt = receipt?.payload?.issued_at;
29
+ if (typeof issuedAt !== 'string'
30
+ || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(issuedAt)
31
+ || Number.isNaN(Date.parse(issuedAt))
32
+ || new Date(issuedAt).toISOString() !== issuedAt) {
33
+ errors.push('issued_at');
34
+ }
35
+ return errors;
36
+ }
37
+ export function verifyPublicationReceipt(receipt, publicKeyPem) {
38
+ if (receipt?.signature_algorithm !== 'ed25519' || !receipt?.payload || !receipt?.signature || !receipt?.key_id) {
39
+ return false;
40
+ }
41
+ if (publicationReceiptContractErrors(receipt).length)
42
+ return false;
43
+ return verify(null, Buffer.from(canonicalJson(receipt.payload)), publicKeyPem, Buffer.from(receipt.signature, 'base64'));
44
+ }
@@ -0,0 +1,32 @@
1
+ import { OntologyKernel } from './ontology-kernel.js';
2
+ export declare class OntologyRegistry {
3
+ [key: string]: any;
4
+ constructor({ rootDir, configDir, publicKeyPem }?: {
5
+ rootDir?: string | undefined;
6
+ configDir?: string | undefined;
7
+ publicKeyPem?: string | undefined;
8
+ });
9
+ hasCurrent(): boolean;
10
+ resolve({ version, asOf }?: any): {
11
+ kernel: OntologyKernel;
12
+ digest: string;
13
+ entry: any;
14
+ publicationVerification: {
15
+ status: string;
16
+ key_id: any;
17
+ signature_algorithm: any;
18
+ trust_source: string;
19
+ receipt_digest: any;
20
+ reason?: undefined;
21
+ } | {
22
+ status: string;
23
+ reason: any;
24
+ key_id?: undefined;
25
+ signature_algorithm?: undefined;
26
+ trust_source?: undefined;
27
+ receipt_digest?: undefined;
28
+ };
29
+ };
30
+ interpretHistory(snapshot?: any, { version, asOf }?: any): any;
31
+ unverifiedHistory(snapshot: any, asOf: any, reason: any): any;
32
+ }
@@ -0,0 +1,202 @@
1
+ // Moved from brainbase-unson server/services/ontology-registry.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
+ import { createHash } from 'node:crypto';
5
+ import { readFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { OntologyError, OntologyKernel } from './ontology-kernel.js';
8
+ import { hasCompleteReceiptMetadata, hasReceiptMetadata, loadTrustedPublicKeys, verifyPublishedReceipt } from './ontology-release-trust.js';
9
+ function parseJson(bytes, label) {
10
+ try {
11
+ return JSON.parse(bytes.toString('utf8'));
12
+ }
13
+ catch (error) {
14
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', `Invalid JSON in ${label}`, {
15
+ cause: error.message
16
+ });
17
+ }
18
+ }
19
+ function parseTime(value, label) {
20
+ const timestamp = Date.parse(value);
21
+ if (!Number.isFinite(timestamp)) {
22
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', `Invalid ${label}: ${value}`);
23
+ }
24
+ return timestamp;
25
+ }
26
+ export class OntologyRegistry {
27
+ constructor({ rootDir = process.cwd(), configDir = 'config/ontology', publicKeyPem = process.env.ONTOLOGY_PUBLICATION_SIGNING_PUBLIC_KEY || '' } = {}) {
28
+ this.configDir = path.resolve(rootDir, configDir);
29
+ this.releasesDir = path.resolve(this.configDir, 'releases');
30
+ this.publicKeyPem = publicKeyPem;
31
+ try {
32
+ this.trustedPublicKeys = loadTrustedPublicKeys(this.configDir);
33
+ }
34
+ catch (error) {
35
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', error.message);
36
+ }
37
+ this.index = parseJson(readFileSync(path.join(this.configDir, 'index.json')), 'ontology index');
38
+ if (!Array.isArray(this.index.releases)) {
39
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', 'Ontology index releases must be an array');
40
+ }
41
+ }
42
+ hasCurrent() {
43
+ return typeof this.index.current === 'string' && this.index.current.length > 0;
44
+ }
45
+ resolve({ version, asOf } = {}) {
46
+ let entry;
47
+ if (version) {
48
+ entry = this.index.releases.find((release) => release.version === version);
49
+ }
50
+ else if (asOf) {
51
+ const target = parseTime(asOf, 'asOf');
52
+ entry = this.index.releases
53
+ .filter((release) => (hasCompleteReceiptMetadata(release) || release.version === this.index.current)
54
+ && parseTime(release.effective_at, 'release effective_at') <= target)
55
+ .sort((left, right) => parseTime(right.effective_at, 'release effective_at') - parseTime(left.effective_at, 'release effective_at'))[0];
56
+ }
57
+ else {
58
+ if (!this.hasCurrent()) {
59
+ throw new OntologyError('ONTOLOGY_CURRENT_UNAVAILABLE', 'No current ontology release has been published');
60
+ }
61
+ entry = this.index.releases.find((release) => release.version === this.index.current);
62
+ }
63
+ if (!entry) {
64
+ throw new OntologyError('ONTOLOGY_VERSION_UNKNOWN', `Ontology release was not found: ${version || asOf}`, {
65
+ version: version || null,
66
+ as_of: asOf || null
67
+ });
68
+ }
69
+ const releasePath = path.resolve(this.configDir, entry.path);
70
+ const relativePath = path.relative(this.releasesDir, releasePath);
71
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
72
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', 'Ontology release path must stay inside releases/', {
73
+ path: entry.path
74
+ });
75
+ }
76
+ const bytes = readFileSync(releasePath);
77
+ const digest = createHash('sha256').update(bytes).digest('hex');
78
+ if (entry.content_digest_algorithm !== 'sha256' || digest !== entry.content_digest) {
79
+ throw new OntologyError('ONTOLOGY_DIGEST_MISMATCH', `Ontology release digest mismatch: ${entry.version}`, {
80
+ version: entry.version,
81
+ expected: entry.content_digest,
82
+ actual: digest
83
+ });
84
+ }
85
+ const manifest = parseJson(bytes, `ontology release ${entry.version}`);
86
+ if (manifest.version !== entry.version || manifest.effective_at !== entry.effective_at) {
87
+ throw new OntologyError('ONTOLOGY_MANIFEST_INVALID', 'Ontology index metadata does not match release manifest', {
88
+ index_version: entry.version,
89
+ manifest_version: manifest.version,
90
+ index_effective_at: entry.effective_at,
91
+ manifest_effective_at: manifest.effective_at
92
+ });
93
+ }
94
+ const receiptTrust = verifyPublishedReceipt({
95
+ configDir: this.configDir,
96
+ entry,
97
+ manifest,
98
+ publicKeyPem: this.publicKeyPem,
99
+ trustedPublicKeys: this.trustedPublicKeys
100
+ });
101
+ const claimsPublication = this.index.current === entry.version
102
+ || entry.status === 'retired'
103
+ || hasReceiptMetadata(entry);
104
+ if (claimsPublication && !receiptTrust.verified) {
105
+ throw new OntologyError('ONTOLOGY_PUBLICATION_UNVERIFIED', `Ontology publication receipt is not trusted: ${entry.version}`, {
106
+ http_status: 503,
107
+ version: entry.version,
108
+ reason: receiptTrust.reason,
109
+ ...receiptTrust.details
110
+ });
111
+ }
112
+ const effectiveStatus = receiptTrust.verified && this.index.current === entry.version
113
+ ? 'active'
114
+ : receiptTrust.verified && entry.status === 'retired'
115
+ ? 'retired'
116
+ : receiptTrust.verified
117
+ ? 'approved'
118
+ : 'proposed';
119
+ return {
120
+ kernel: new OntologyKernel({ manifest, status: effectiveStatus }),
121
+ digest,
122
+ entry: structuredClone(entry),
123
+ publicationVerification: receiptTrust.verified
124
+ ? {
125
+ status: 'verified',
126
+ key_id: receiptTrust.receipt.key_id,
127
+ signature_algorithm: receiptTrust.receipt.signature_algorithm,
128
+ trust_source: this.publicKeyPem ? 'environment_override' : 'git_trust_store',
129
+ receipt_digest: receiptTrust.receipt_digest
130
+ }
131
+ : { status: 'unverified', reason: receiptTrust.reason }
132
+ };
133
+ }
134
+ interpretHistory(snapshot = {}, { version, asOf } = {}) {
135
+ const recordedVersion = snapshot.ontology_version || null;
136
+ if (version && recordedVersion && version !== recordedVersion) {
137
+ throw new OntologyError('ONTOLOGY_HISTORY_VERSION_MISMATCH', 'Requested ontology version does not match the recorded fact version', {
138
+ requested_version: version,
139
+ recorded_version: recordedVersion
140
+ });
141
+ }
142
+ let release;
143
+ try {
144
+ if (version || recordedVersion) {
145
+ release = this.resolve({ version: version || recordedVersion });
146
+ }
147
+ else if (asOf) {
148
+ release = this.resolve({ asOf });
149
+ }
150
+ else {
151
+ return this.unverifiedHistory(snapshot, asOf, {
152
+ code: 'ONTOLOGY_HISTORY_VERSION_UNRESOLVED',
153
+ message: 'Historical interpretation requires a recorded ontology version or resolvable as-of time'
154
+ });
155
+ }
156
+ }
157
+ catch (error) {
158
+ if (!version && !recordedVersion && error instanceof OntologyError
159
+ && ['ONTOLOGY_VERSION_UNKNOWN', 'ONTOLOGY_CURRENT_UNAVAILABLE', 'ONTOLOGY_PUBLICATION_UNVERIFIED'].includes(error.code)) {
160
+ return this.unverifiedHistory(snapshot, asOf, {
161
+ code: error.code,
162
+ message: error.message
163
+ });
164
+ }
165
+ throw error;
166
+ }
167
+ if (release.kernel.status === 'proposed') {
168
+ return this.unverifiedHistory(snapshot, asOf, {
169
+ code: 'ONTOLOGY_PUBLICATION_UNVERIFIED',
170
+ message: `Ontology release has not been published: ${release.entry.version}`
171
+ });
172
+ }
173
+ const resolvedVersion = release.entry.version;
174
+ const mismatchedEvents = (snapshot.evolution_events || [])
175
+ .filter((event) => event.ontology_version && event.ontology_version !== resolvedVersion)
176
+ .map((event) => event.event_id);
177
+ if (mismatchedEvents.length) {
178
+ throw new OntologyError('ONTOLOGY_HISTORY_VERSION_MISMATCH', 'Evolution events must use the recorded fact ontology version', {
179
+ recorded_version: recordedVersion,
180
+ resolved_version: resolvedVersion,
181
+ event_ids: mismatchedEvents
182
+ });
183
+ }
184
+ return {
185
+ ...release.kernel.interpretHistory(snapshot, { asOf }),
186
+ recorded_ontology_version: recordedVersion,
187
+ resolved_ontology_version: resolvedVersion,
188
+ verification: 'verified'
189
+ };
190
+ }
191
+ unverifiedHistory(snapshot, asOf, reason) {
192
+ return {
193
+ ...structuredClone(snapshot),
194
+ ontology_version: null,
195
+ recorded_ontology_version: snapshot.ontology_version || null,
196
+ resolved_ontology_version: null,
197
+ interpretation_as_of: asOf || null,
198
+ verification: 'unverified',
199
+ unverified_reason: reason
200
+ };
201
+ }
202
+ }
@@ -0,0 +1,4 @@
1
+ export declare function hasReceiptMetadata(entry?: any): boolean;
2
+ export declare function hasCompleteReceiptMetadata(entry?: any): boolean;
3
+ export declare function loadTrustedPublicKeys(configDir: any): any;
4
+ export declare function verifyPublishedReceipt({ configDir, entry, manifest, publicKeyPem, trustedPublicKeys }: any): any;
@@ -0,0 +1,121 @@
1
+ // Moved from brainbase-unson server/services/ontology-release-trust.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
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { canonicalJson, publicationReceiptContractErrors, sha256, verifyPublicationReceipt } from './ontology-publication.js';
7
+ const SHA256_DIGEST = /^[a-f0-9]{64}$/;
8
+ const RECEIPT_FIELDS = ['receipt_path', 'receipt_digest_algorithm', 'receipt_digest'];
9
+ export function hasReceiptMetadata(entry = {}) {
10
+ return RECEIPT_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(entry, field));
11
+ }
12
+ export function hasCompleteReceiptMetadata(entry = {}) {
13
+ return typeof entry.receipt_path === 'string'
14
+ && entry.receipt_path.trim().length > 0
15
+ && entry.receipt_digest_algorithm === 'sha256'
16
+ && typeof entry.receipt_digest === 'string'
17
+ && SHA256_DIGEST.test(entry.receipt_digest);
18
+ }
19
+ function invalid(reason, details = {}) {
20
+ return { verified: false, reason, details };
21
+ }
22
+ function isContained(parent, child) {
23
+ const relative = path.relative(parent, child);
24
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
25
+ }
26
+ export function loadTrustedPublicKeys(configDir) {
27
+ const trustStorePath = path.join(configDir, 'trusted-public-keys.json');
28
+ if (!existsSync(trustStorePath))
29
+ return {};
30
+ const trustStore = JSON.parse(readFileSync(trustStorePath, 'utf8'));
31
+ if (trustStore.schema_version !== '1.0.0'
32
+ || !trustStore.keys
33
+ || Array.isArray(trustStore.keys)
34
+ || typeof trustStore.keys !== 'object') {
35
+ throw new Error('Ontology trusted public keys must use the 1.0.0 key map schema');
36
+ }
37
+ for (const [keyId, key] of Object.entries(trustStore.keys)) {
38
+ if (!keyId
39
+ || key?.algorithm !== 'ed25519'
40
+ || typeof key.public_key_pem !== 'string'
41
+ || !key.public_key_pem.includes('-----BEGIN PUBLIC KEY-----')) {
42
+ throw new Error(`Invalid ontology trusted public key: ${keyId || '<empty>'}`);
43
+ }
44
+ }
45
+ return trustStore.keys;
46
+ }
47
+ export function verifyPublishedReceipt({ configDir, entry = {}, manifest, publicKeyPem = '', trustedPublicKeys = {} }) {
48
+ if (!hasCompleteReceiptMetadata(entry)) {
49
+ return invalid(hasReceiptMetadata(entry) ? 'incomplete_metadata' : 'missing_metadata');
50
+ }
51
+ const publicationsDir = path.resolve(configDir, 'publications');
52
+ const receiptPath = path.resolve(configDir, entry.receipt_path);
53
+ if (!isContained(publicationsDir, receiptPath)) {
54
+ return invalid('path_escape');
55
+ }
56
+ let realPublicationsDir;
57
+ let realReceiptPath;
58
+ let receiptBytes;
59
+ try {
60
+ realPublicationsDir = realpathSync(publicationsDir);
61
+ realReceiptPath = realpathSync(receiptPath);
62
+ if (!isContained(realPublicationsDir, realReceiptPath) || !statSync(realReceiptPath).isFile()) {
63
+ return invalid('path_escape_or_not_file');
64
+ }
65
+ receiptBytes = readFileSync(realReceiptPath);
66
+ }
67
+ catch {
68
+ return invalid('receipt_unavailable');
69
+ }
70
+ const actualDigest = sha256(receiptBytes);
71
+ if (actualDigest !== entry.receipt_digest) {
72
+ return invalid('digest_mismatch', { expected: entry.receipt_digest, actual: actualDigest });
73
+ }
74
+ let receipt;
75
+ try {
76
+ receipt = JSON.parse(receiptBytes.toString('utf8'));
77
+ }
78
+ catch {
79
+ return invalid('invalid_json');
80
+ }
81
+ const contractErrors = publicationReceiptContractErrors(receipt);
82
+ if (contractErrors.length) {
83
+ return invalid('contract_mismatch', { fields: contractErrors });
84
+ }
85
+ const trustedPublicKeyPem = publicKeyPem || trustedPublicKeys[receipt.key_id]?.public_key_pem || '';
86
+ if (!trustedPublicKeyPem) {
87
+ return invalid('public_key_unavailable');
88
+ }
89
+ try {
90
+ if (!verifyPublicationReceipt(receipt, trustedPublicKeyPem)) {
91
+ return invalid('signature_invalid');
92
+ }
93
+ }
94
+ catch {
95
+ return invalid('signature_invalid');
96
+ }
97
+ const governance = manifest?.governance || {};
98
+ const expectedBindings = {
99
+ release_version: entry.version,
100
+ release_digest: entry.content_digest,
101
+ source_commit_sha: entry.source_commit_sha,
102
+ decision_id: governance.decision_id,
103
+ scope_entity_id: governance.scope_entity_id,
104
+ proposer_entity_id: governance.proposer_entity_id,
105
+ decider_entity_id: governance.decider_entity_id,
106
+ applier_entity_id: governance.applier_entity_id,
107
+ actor_entity_id: governance.applier_entity_id
108
+ };
109
+ const mismatches = Object.entries(expectedBindings)
110
+ .filter(([key, value]) => !value || receipt.payload?.[key] !== value)
111
+ .map(([key]) => key);
112
+ const manifestImpactScope = canonicalJson(manifest?.impact_scope);
113
+ if (canonicalJson(receipt.payload?.impact_scope) !== manifestImpactScope
114
+ || (entry.impact_scope && canonicalJson(entry.impact_scope) !== manifestImpactScope)) {
115
+ mismatches.push('impact_scope');
116
+ }
117
+ if (mismatches.length) {
118
+ return invalid('binding_mismatch', { fields: mismatches });
119
+ }
120
+ return { verified: true, receipt, receipt_path: realReceiptPath, receipt_digest: actualDigest };
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -195,6 +195,22 @@
195
195
  "types": "./dist/memory-scope-policy.d.ts",
196
196
  "import": "./dist/memory-scope-policy.js"
197
197
  },
198
+ "./ontology-kernel": {
199
+ "types": "./dist/ontology-kernel.d.ts",
200
+ "import": "./dist/ontology-kernel.js"
201
+ },
202
+ "./ontology-registry": {
203
+ "types": "./dist/ontology-registry.d.ts",
204
+ "import": "./dist/ontology-registry.js"
205
+ },
206
+ "./ontology-publication": {
207
+ "types": "./dist/ontology-publication.d.ts",
208
+ "import": "./dist/ontology-publication.js"
209
+ },
210
+ "./ontology-release-trust": {
211
+ "types": "./dist/ontology-release-trust.d.ts",
212
+ "import": "./dist/ontology-release-trust.js"
213
+ },
198
214
  "./canonical-document-writer": {
199
215
  "types": "./dist/canonical-document-writer.d.ts",
200
216
  "import": "./dist/canonical-document-writer.js"
@@ -463,5 +479,5 @@
463
479
  "engines": {
464
480
  "node": ">=20"
465
481
  },
466
- "gitHead": "c3c5a1b3e4d5de936ecebc7ab86346bfdd49faf8"
482
+ "gitHead": "e343d28419451fde27b9d35b1c226fc769e68420"
467
483
  }
@@ -1514,6 +1514,47 @@ function memoryLines(answer) {
1514
1514
  return lines;
1515
1515
  }
1516
1516
 
1517
+ /**
1518
+ * @param {() => (WaitingQueuesAnswer | Promise<WaitingQueuesAnswer>)} [options.waitingQueues] Other queues of things
1519
+ * the owner decides, listed next to the waiting judgments (story-world-waiting-queues-v1, ledger P17 / D9). The
1520
+ * world knows nothing about them beyond `{ status: 'available', queues: [{ id, label, count, href, link_label? }] }`.
1521
+ * A queue with zero is not shown; an unreadable one is never shown as zero. `href` must be `https:` or a link
1522
+ * inside the host (`/…`, `?…`); others get no link. Without it the world is drawn exactly as before.
1523
+ * @typedef {{ status: string, reason?: string, queues?: object[] }} WaitingQueuesAnswer
1524
+ */
1525
+ async function readWaitingQueues(waitingQueues) {
1526
+ if (typeof waitingQueues !== 'function') return null;
1527
+ try {
1528
+ const answer = await waitingQueues();
1529
+ return answer && typeof answer === 'object' ? answer : { status: 'failed', reason: 'invalid_answer' };
1530
+ } catch (error) {
1531
+ return { status: 'failed', reason: error instanceof Error ? error.message : 'request_failed' };
1532
+ }
1533
+ }
1534
+
1535
+ /** The waiting queues as world notes. Zero is quiet; unknown is said out loud and never counted as zero. */
1536
+ function waitingQueueLines(answer) {
1537
+ if (!answer) return [];
1538
+ if (answer.status !== 'available' || !Array.isArray(answer.queues)) {
1539
+ return [{ text: `ほかの待っているもの:読めない(${answer.reason ?? answer.status ?? '理由不明'})。件数は0件とは確認できません。`, tone: 'warning', summary: 'ほかの待っているもの:読めない', link: null }];
1540
+ }
1541
+ const lines = [];
1542
+ for (const queue of answer.queues) {
1543
+ const label = typeof queue?.label === 'string' && queue.label.trim() ? queue.label.trim() : null;
1544
+ if (!label) continue;
1545
+ const count = queue.count;
1546
+ if (!Number.isInteger(count) || count < 0) {
1547
+ lines.push({ text: `${label}:読めない(${queue.reason ?? '理由不明'})。件数は0件とは確認できません。`, tone: 'warning', summary: `${label}:読めない`, link: null });
1548
+ continue;
1549
+ }
1550
+ if (count === 0) continue;
1551
+ const href = exitHrefKind(queue.href) ? queue.href : null;
1552
+ const linkLabel = typeof queue.link_label === 'string' && queue.link_label.trim() ? queue.link_label.trim() : `${label}を開く`;
1553
+ lines.push({ text: `${label} ${count}件。`, tone: 'attention', summary: `${label} ${count}件`, link: href ? { href, label: linkLabel, className: 'bb-world-waiting-queue-link' } : null });
1554
+ }
1555
+ return lines;
1556
+ }
1557
+
1517
1558
  /** At most this many waiting judgments are listed in the world; the rest are counted, never hidden. */
1518
1559
  const WAITING_LIST_LIMIT = 3;
1519
1560
 
@@ -1540,7 +1581,7 @@ function defaultJudgmentHref(decisionAttemptId) {
1540
1581
  return decisionAttemptId ? `#value-proofs?decision=${encodeURIComponent(decisionAttemptId)}` : '#value-proofs';
1541
1582
  }
1542
1583
 
1543
- export function createWorldView({ root, rail, page, presentation = 'standard', document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, judgmentHref = defaultJudgmentHref, selection = addressSelection(), businessExits = null, memoryStatus = null }) {
1584
+ export function createWorldView({ root, rail, page, presentation = 'standard', document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, judgmentHref = defaultJudgmentHref, selection = addressSelection(), businessExits = null, memoryStatus = null, waitingQueues = null }) {
1544
1585
  const exitsEnabled = typeof businessExits === 'function';
1545
1586
  const doc = explicitDocument ?? globalThis.document;
1546
1587
  const reducedMotion = Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
@@ -2384,6 +2425,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2384
2425
 
2385
2426
  async function load() {
2386
2427
  const memoryRead = readMemoryStatus(memoryStatus);
2428
+ const queuesRead = readWaitingQueues(waitingQueues);
2387
2429
  const businessesResult = await readJson(fetcher, '/api/extensions/world/businesses');
2388
2430
  if (disposed) return;
2389
2431
  // A world drawn from an organization's Graph shows only that organization's judgments: the host keeps
@@ -2463,6 +2505,9 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2463
2505
  if (judgmentsConnected) {
2464
2506
  for (const line of waitingLines(proofs, judgmentHref)) note('待っている判断', line.text, 'attention', line.summary, line.link);
2465
2507
  }
2508
+ const queues = await queuesRead;
2509
+ if (disposed) return;
2510
+ for (const line of waitingQueueLines(queues)) note('待っている', line.text, line.tone, line.summary, line.link);
2466
2511
  const rowItems = new Map(rows.map((row) => [row.key, Array.isArray(row.items) ? row.items : []]));
2467
2512
  const memory = await memoryRead;
2468
2513
  if (disposed) return;