@unson/brainbase-mcp 0.33.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/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 +18 -2
|
@@ -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.
|
|
3
|
+
"version": "0.34.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": "
|
|
482
|
+
"gitHead": "9275d1ad54f053c85229a44b1c4cba811969cd29"
|
|
467
483
|
}
|