@unson/brainbase-mcp 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,804 @@
1
+ // Moved from brainbase-unson server/services/graph-maintenance-engine.js without changing behavior.
2
+ // Only type annotations were added; graph records stay open JSON objects as in the original.
3
+ /* eslint-disable @typescript-eslint/no-explicit-any */
4
+ import { createHash } from 'node:crypto';
5
+ export const GRAPH_MAINTENANCE_OPERATIONS = Object.freeze([
6
+ 'patch_entity', 'merge_entities', 'retire_entity', 'reactivate_project', 'move_scope', 'rehome_entity',
7
+ 'upsert_edge', 'link_decision_subject', 'materialize_project_subject',
8
+ 'link_decision_project_subject', 'retire_edge', 'normalize_alias',
9
+ 'normalize_merged_lifecycle', 'create_entity', 'delete_entity', 'delete_edge',
10
+ 'add_shared_membership'
11
+ ]);
12
+ export const GRAPH_MAINTENANCE_MAX_OPERATIONS = 100;
13
+ const SENSITIVITY_RANK = { internal: 0, restricted: 1, finance: 2, hr: 2, contract: 2 };
14
+ const ROLE_RANK = { member: 1, gm: 2, ceo: 3 };
15
+ const LIFECYCLE_STATUSES = new Set(['active', 'retired', 'superseded']);
16
+ function canonicalize(value) {
17
+ if (Array.isArray(value))
18
+ return value.map(canonicalize);
19
+ if (!value || typeof value !== 'object')
20
+ return value;
21
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
22
+ }
23
+ function withoutHash(snapshot) {
24
+ const copy = structuredClone(snapshot);
25
+ delete copy.hash;
26
+ delete copy.snapshot_hash;
27
+ delete copy.snapshot_id;
28
+ delete copy.exported_at;
29
+ copy.entities = [...(copy.entities || [])].sort((a, b) => a.id.localeCompare(b.id));
30
+ if (Object.hasOwn(copy, 'external_entities')) {
31
+ copy.external_entities = [...(copy.external_entities || [])].sort((a, b) => a.id.localeCompare(b.id));
32
+ }
33
+ copy.edges = [...(copy.edges || [])].sort((a, b) => a.id.localeCompare(b.id));
34
+ return copy;
35
+ }
36
+ export function hashGraphSnapshot(snapshot) {
37
+ return `sha256:${createHash('sha256').update(JSON.stringify(canonicalize(withoutHash(snapshot)))).digest('hex')}`;
38
+ }
39
+ function requireVersion(record, operation) {
40
+ if (!Number.isInteger(operation.expected_version))
41
+ throw new Error('expected_version is required');
42
+ if (record.version !== operation.expected_version)
43
+ throw new Error('expected_version conflict');
44
+ }
45
+ function assertSensitivity(current, next) {
46
+ if (!(current in SENSITIVITY_RANK))
47
+ throw new Error('Invalid sensitivity');
48
+ if (next === undefined)
49
+ return;
50
+ if (!(next in SENSITIVITY_RANK))
51
+ throw new Error('Invalid sensitivity');
52
+ if (SENSITIVITY_RANK[next] < SENSITIVITY_RANK[current])
53
+ throw new Error('sensitivity cannot be lowered');
54
+ }
55
+ function assertRoleMinimum(current, next) {
56
+ if (!(current in ROLE_RANK))
57
+ throw new Error('Invalid role_min');
58
+ if (next === undefined)
59
+ return;
60
+ if (!(next in ROLE_RANK))
61
+ throw new Error('Invalid role_min');
62
+ if (ROLE_RANK[next] < ROLE_RANK[current])
63
+ throw new Error('role_min cannot be lowered');
64
+ }
65
+ function assertAccessMetadata(roleMin, sensitivity) {
66
+ if (!(roleMin in ROLE_RANK))
67
+ throw new Error('Invalid role_min');
68
+ if (!(sensitivity in SENSITIVITY_RANK))
69
+ throw new Error('Invalid sensitivity');
70
+ if (SENSITIVITY_RANK[sensitivity] >= SENSITIVITY_RANK.finance && roleMin === 'member') {
71
+ throw new Error('Sensitive data requires role_min gm or ceo');
72
+ }
73
+ }
74
+ function validationError(validation, prefix = 'Graph snapshot is invalid') {
75
+ return new Error(`${prefix}: ${validation.issues.map((item) => item.category).join(',')}`);
76
+ }
77
+ function validationIssueKey(issue) {
78
+ return JSON.stringify(canonicalize(issue));
79
+ }
80
+ function introducedValidationIssues(before, after) {
81
+ const remaining = new Map();
82
+ for (const issue of before.issues) {
83
+ const key = validationIssueKey(issue);
84
+ remaining.set(key, (remaining.get(key) || 0) + 1);
85
+ }
86
+ return after.issues.filter((issue) => {
87
+ const key = validationIssueKey(issue);
88
+ const count = remaining.get(key) || 0;
89
+ if (count === 0)
90
+ return true;
91
+ remaining.set(key, count - 1);
92
+ return false;
93
+ });
94
+ }
95
+ export function findIntroducedGraphValidationIssues(beforeSnapshot, afterSnapshot) {
96
+ return introducedValidationIssues(validateGraphSnapshot(beforeSnapshot), validateGraphSnapshot(afterSnapshot));
97
+ }
98
+ function findEntity(state, id) {
99
+ const entity = (state.entities || []).find((item) => item.id === id);
100
+ if (!entity)
101
+ throw new Error(`Unknown entity: ${id}`);
102
+ return entity;
103
+ }
104
+ function findAnyEntity(state, id) {
105
+ const entity = [...(state.entities || []), ...(state.external_entities || [])].find((item) => item.id === id);
106
+ if (!entity)
107
+ throw new Error(`Unknown entity: ${id}`);
108
+ return entity;
109
+ }
110
+ function findAnyEntityOrNull(state, id) {
111
+ return [...(state.entities || []), ...(state.external_entities || [])].find((item) => item.id === id) || null;
112
+ }
113
+ function assertProjectCatalogProvenance(project) {
114
+ const catalogProjectId = project.payload?.catalog_project_id;
115
+ if (catalogProjectId !== project.id) {
116
+ throw new Error('Project subject catalog_project_id must match Graph Entity ID');
117
+ }
118
+ const catalogVersion = project.payload?.catalog_version;
119
+ if (!Number.isInteger(catalogVersion) || catalogVersion < 1) {
120
+ throw new Error('Project subject catalog_version must be a positive integer');
121
+ }
122
+ const expectedSourceRef = `project-catalog:${catalogProjectId}@${catalogVersion}`;
123
+ if (project.payload?.source_ref !== expectedSourceRef) {
124
+ throw new Error('Project subject source_ref must match Catalog Project identity and version');
125
+ }
126
+ if (!String(project.payload?.name || '').trim()) {
127
+ throw new Error('Project subject Catalog name is required');
128
+ }
129
+ }
130
+ function isActiveDecision(entity) {
131
+ return entity.entity_type === 'decision'
132
+ && entity.lifecycle_status === 'active'
133
+ && !['retired', 'superseded'].includes(String(entity.payload?.status || '').toLowerCase());
134
+ }
135
+ function findEdge(state, operation) {
136
+ const edge = operation.edge_id
137
+ ? state.edges.find((item) => item.id === operation.edge_id)
138
+ : state.edges.find((item) => item.from_id === operation.from_id && item.to_id === operation.to_id && item.rel_type === operation.rel_type);
139
+ if (!edge)
140
+ throw new Error('Unknown edge');
141
+ return edge;
142
+ }
143
+ const SHARED_MEMBERSHIP_DATE = /^\d{4}-\d{2}-\d{2}$/;
144
+ /**
145
+ * Add one person -> project member_of edge for a shared Person that lives
146
+ * outside this project. The Person and every existing edge stay unchanged;
147
+ * an existing active membership makes the operation a no-op.
148
+ */
149
+ function addSharedMembership(state, operation, humanGateReceipt) {
150
+ const decision = findEntity(state, operation.decision_id);
151
+ const project = findEntity(state, operation.target_project_entity_id);
152
+ const person = (state.external_entities || []).find((item) => item.id === operation.subject_entity_id);
153
+ if (!person)
154
+ throw new Error('Shared person endpoint is required');
155
+ requireVersion(decision, { expected_version: operation.decision_expected_version });
156
+ requireVersion(person, { expected_version: operation.subject_expected_version });
157
+ requireVersion(project, { expected_version: operation.target_project_expected_version });
158
+ if (!isActiveDecision(decision) || decision.project_code !== state.project_code) {
159
+ throw new Error('active Decision in the target project is required');
160
+ }
161
+ if (person.entity_type !== 'person' || person.lifecycle_status !== 'active'
162
+ || !['shared_person', 'same_organization'].includes(person.reference_scope)) {
163
+ throw new Error('active shared Person is required');
164
+ }
165
+ if (project.entity_type !== 'project' || project.lifecycle_status !== 'active'
166
+ || project.project_code !== state.project_code) {
167
+ throw new Error('active Project entity in the target project is required');
168
+ }
169
+ if (operation.target_project_code !== state.project_code)
170
+ throw new Error('target_project_code must be the plan project');
171
+ const receipt = operation.human_gate_receipt || humanGateReceipt;
172
+ if (!String(receipt || '').trim())
173
+ throw new Error('human_gate_receipt is required for shared membership');
174
+ const basis = String(operation.basis || '').trim();
175
+ if (!basis || basis.length > 1000)
176
+ throw new Error('basis is required (at most 1000 characters)');
177
+ const confirmedAt = String(operation.confirmed_at || '');
178
+ if (!SHARED_MEMBERSHIP_DATE.test(confirmedAt) || Number.isNaN(Date.parse(`${confirmedAt}T00:00:00Z`))) {
179
+ throw new Error('confirmed_at must be a YYYY-MM-DD date');
180
+ }
181
+ const existing = state.edges.find((edge) => edge.from_id === person.id && edge.to_id === project.id
182
+ && edge.rel_type === 'member_of');
183
+ if (existing?.lifecycle_status === 'active')
184
+ return;
185
+ if (existing)
186
+ throw new Error('A retired membership exists; reactivate it with upsert_edge');
187
+ if (operation.expected_version !== 0)
188
+ throw new Error('expected_version conflict');
189
+ if (!String(operation.edge_id || '').trim())
190
+ throw new Error('edge_id is required');
191
+ if (state.edges.some((edge) => edge.id === operation.edge_id))
192
+ throw new Error('edge id conflict');
193
+ state.edges.push({
194
+ id: operation.edge_id,
195
+ from_id: person.id,
196
+ to_id: project.id,
197
+ rel_type: 'member_of',
198
+ project_code: state.project_code,
199
+ payload: {
200
+ source: 'graph_maintenance:add_shared_membership',
201
+ source_graph_scope: 'shared_person',
202
+ basis,
203
+ confirmed_at: confirmedAt,
204
+ decision_id: decision.id,
205
+ human_gate_receipt: receipt
206
+ },
207
+ role_min: 'member',
208
+ sensitivity: 'internal',
209
+ lifecycle_status: 'active',
210
+ version: 1
211
+ });
212
+ }
213
+ export function applyGraphOperations(snapshot, operations, { projectCode, humanGateReceipt } = {}) {
214
+ const initialValidation = validateGraphSnapshot(snapshot);
215
+ const state = withoutHash(snapshot);
216
+ if (state.project_code !== projectCode)
217
+ throw new Error('project scope mismatch');
218
+ for (const operation of operations) {
219
+ if (!GRAPH_MAINTENANCE_OPERATIONS.includes(operation.operation))
220
+ throw new Error(`Unknown graph operation: ${operation.operation}`);
221
+ if (operation.operation === 'create_entity') {
222
+ if (operation.expected_version !== 0)
223
+ throw new Error('expected_version conflict');
224
+ const entityId = String(operation.entity_id || '').trim();
225
+ const entityType = String(operation.entity_type || '').trim();
226
+ if (!entityId)
227
+ throw new Error('entity_id is required');
228
+ if (!entityType)
229
+ throw new Error('entity_type is required');
230
+ // Project subjects carry Project Catalog provenance and are created
231
+ // only through materialize_project_subject.
232
+ if (entityType === 'project')
233
+ throw new Error('Project entities must use materialize_project_subject');
234
+ if (operation.payload !== undefined
235
+ && (!operation.payload || typeof operation.payload !== 'object' || Array.isArray(operation.payload))) {
236
+ throw new Error('payload must be an object');
237
+ }
238
+ if (findAnyEntityOrNull(state, entityId))
239
+ throw new Error('entity id conflict');
240
+ const roleMin = operation.role_min || 'member';
241
+ const sensitivity = operation.sensitivity || 'internal';
242
+ assertAccessMetadata(roleMin, sensitivity);
243
+ state.entities.push({
244
+ id: entityId,
245
+ entity_type: entityType,
246
+ project_code: state.project_code,
247
+ payload: structuredClone(operation.payload || {}),
248
+ role_min: roleMin,
249
+ sensitivity,
250
+ lifecycle_status: 'active',
251
+ version: 1
252
+ });
253
+ }
254
+ else if (operation.operation === 'delete_entity') {
255
+ // Physical removal. retire_entity remains the logical lifecycle change.
256
+ const entity = findEntity(state, operation.entity_id);
257
+ if (entity.entity_type === 'project')
258
+ throw new Error('Project entities cannot be physically deleted; use retire_entity');
259
+ requireVersion(entity, operation);
260
+ if (isActiveDecision(entity) && !(operation.human_gate_receipt || humanGateReceipt)) {
261
+ throw new Error('human_gate_receipt is required for Active Decision');
262
+ }
263
+ if (state.edges.some((edge) => edge.from_id === entity.id || edge.to_id === entity.id)) {
264
+ throw new Error('entity still has edges; delete them in the same plan first');
265
+ }
266
+ state.entities = state.entities.filter((item) => item.id !== entity.id);
267
+ }
268
+ else if (operation.operation === 'delete_edge') {
269
+ const edge = findEdge(state, operation);
270
+ requireVersion(edge, operation);
271
+ state.edges = state.edges.filter((item) => item.id !== edge.id);
272
+ }
273
+ else if (operation.operation === 'patch_entity') {
274
+ const entity = findEntity(state, operation.entity_id);
275
+ requireVersion(entity, operation);
276
+ assertSensitivity(entity.sensitivity, operation.sensitivity);
277
+ assertRoleMinimum(entity.role_min, operation.role_min);
278
+ entity.payload = { ...entity.payload, ...(operation.patch || {}) };
279
+ if (operation.role_min !== undefined)
280
+ entity.role_min = operation.role_min;
281
+ if (operation.sensitivity !== undefined)
282
+ entity.sensitivity = operation.sensitivity;
283
+ assertAccessMetadata(entity.role_min, entity.sensitivity);
284
+ entity.version += 1;
285
+ }
286
+ else if (operation.operation === 'retire_entity') {
287
+ const entity = findEntity(state, operation.entity_id);
288
+ requireVersion(entity, operation);
289
+ if (isActiveDecision(entity) && !(operation.human_gate_receipt || humanGateReceipt))
290
+ throw new Error('human_gate_receipt is required for Active Decision');
291
+ entity.lifecycle_status = 'retired';
292
+ entity.version += 1;
293
+ }
294
+ else if (operation.operation === 'reactivate_project') {
295
+ const entity = findEntity(state, operation.entity_id);
296
+ requireVersion(entity, operation);
297
+ if (entity.entity_type !== 'project')
298
+ throw new Error('reactivate_project requires a Project entity');
299
+ if (entity.lifecycle_status !== 'retired')
300
+ throw new Error('reactivate_project requires a retired Project');
301
+ entity.lifecycle_status = 'active';
302
+ entity.version += 1;
303
+ }
304
+ else if (operation.operation === 'move_scope') {
305
+ const entity = findEntity(state, operation.entity_id);
306
+ requireVersion(entity, operation);
307
+ if (!operation.target_project_code)
308
+ throw new Error('target_project_code is required');
309
+ entity.project_code = operation.target_project_code;
310
+ entity.version += 1;
311
+ for (const edge of state.edges.filter((item) => item.from_id === entity.id || item.to_id === entity.id)) {
312
+ edge.project_code = operation.target_project_code;
313
+ edge.version += 1;
314
+ }
315
+ }
316
+ else if (operation.operation === 'rehome_entity') {
317
+ const entity = findEntity(state, operation.entity_id);
318
+ requireVersion(entity, operation);
319
+ if (!String(operation.target_project_code || '').trim())
320
+ throw new Error('target_project_code is required');
321
+ if (!String(operation.target_project_entity_id || '').trim())
322
+ throw new Error('target_project_entity_id is required');
323
+ const targetProject = findEntity(state, operation.target_project_entity_id);
324
+ requireVersion(targetProject, { expected_version: operation.target_project_expected_version });
325
+ if (targetProject.entity_type !== 'project')
326
+ throw new Error('target Project entity_type is required');
327
+ if (targetProject.lifecycle_status !== 'active')
328
+ throw new Error('target Project must be active');
329
+ if (targetProject.project_code !== operation.target_project_code)
330
+ throw new Error('target Project scope mismatch');
331
+ if (!String(operation.membership_edge_id || '').trim())
332
+ throw new Error('membership_edge_id is required');
333
+ const membership = findEdge(state, { edge_id: operation.membership_edge_id });
334
+ requireVersion(membership, { expected_version: operation.membership_expected_version });
335
+ if (membership.rel_type !== 'belongs_to_project'
336
+ || membership.from_id !== entity.id
337
+ || membership.lifecycle_status !== 'active') {
338
+ throw new Error('active belongs_to_project membership is required');
339
+ }
340
+ if (membership.project_code !== entity.project_code)
341
+ throw new Error('membership source scope mismatch');
342
+ if (operation.new_membership_expected_version !== 0)
343
+ throw new Error('expected_version conflict');
344
+ if (!String(operation.new_membership_edge_id || '').trim())
345
+ throw new Error('new_membership_edge_id is required');
346
+ if (state.edges.some((edge) => edge.id === operation.new_membership_edge_id))
347
+ throw new Error('edge id conflict');
348
+ membership.lifecycle_status = 'retired';
349
+ membership.version += 1;
350
+ entity.project_code = operation.target_project_code;
351
+ entity.version += 1;
352
+ state.edges.push({
353
+ ...structuredClone(membership),
354
+ id: operation.new_membership_edge_id,
355
+ from_id: entity.id,
356
+ to_id: targetProject.id,
357
+ project_code: operation.target_project_code,
358
+ lifecycle_status: 'active',
359
+ version: 1
360
+ });
361
+ }
362
+ else if (operation.operation === 'merge_entities') {
363
+ const source = findEntity(state, operation.source_entity_id);
364
+ const target = findEntity(state, operation.target_entity_id);
365
+ requireVersion(source, { expected_version: operation.source_expected_version });
366
+ requireVersion(target, { expected_version: operation.target_expected_version });
367
+ if (source.project_code !== target.project_code)
368
+ throw new Error('project scope mismatch');
369
+ assertSensitivity(source.sensitivity, target.sensitivity);
370
+ assertRoleMinimum(source.role_min, target.role_min);
371
+ target.payload = { ...source.payload, ...target.payload, aliases: [...new Set([...(target.payload?.aliases || []), source.id, ...(source.payload?.aliases || [])])] };
372
+ target.version += 1;
373
+ source.lifecycle_status = 'superseded';
374
+ source.payload = { ...source.payload, canonical_entity_id: target.id };
375
+ source.version += 1;
376
+ for (const edge of state.edges) {
377
+ let changed = false;
378
+ if (edge.from_id === source.id) {
379
+ edge.from_id = target.id;
380
+ changed = true;
381
+ }
382
+ if (edge.to_id === source.id) {
383
+ edge.to_id = target.id;
384
+ changed = true;
385
+ }
386
+ if (changed)
387
+ edge.version += 1;
388
+ }
389
+ }
390
+ else if (operation.operation === 'normalize_merged_lifecycle') {
391
+ const source = findEntity(state, operation.entity_id);
392
+ const target = findEntity(state, operation.target_entity_id);
393
+ requireVersion(source, operation);
394
+ requireVersion(target, { expected_version: operation.target_expected_version });
395
+ if (source.id === target.id)
396
+ throw new Error('source and target must differ');
397
+ if (source.project_code !== target.project_code)
398
+ throw new Error('project scope mismatch');
399
+ if (source.entity_type !== target.entity_type)
400
+ throw new Error('entity_type mismatch');
401
+ if (source.lifecycle_status !== 'merged')
402
+ throw new Error('source lifecycle_status must be merged');
403
+ if (source.payload?.canonical_entity_id !== target.id)
404
+ throw new Error('canonical_entity_id mismatch');
405
+ if (target.lifecycle_status !== 'active')
406
+ throw new Error('target must be active');
407
+ if (state.edges.some((edge) => edge.lifecycle_status === 'active'
408
+ && (edge.from_id === source.id || edge.to_id === source.id))) {
409
+ throw new Error('source still has active edges');
410
+ }
411
+ source.lifecycle_status = 'superseded';
412
+ source.version += 1;
413
+ }
414
+ else if (operation.operation === 'upsert_edge') {
415
+ const externalIds = new Set((state.external_entities || []).map((entity) => entity.id));
416
+ const toEntity = findAnyEntity(state, operation.to_id);
417
+ const localFrom = (state.entities || []).find((item) => item.id === operation.from_id) || null;
418
+ const externalFrom = localFrom ? null : findAnyEntityOrNull(state, operation.from_id);
419
+ // The accountable person of a tool: a same-organization person outside this project.
420
+ const sameOrganizationToolAccountable = operation.rel_type === 'accountable_for'
421
+ && externalFrom?.entity_type === 'person'
422
+ && externalFrom.lifecycle_status === 'active'
423
+ && externalFrom.reference_scope === 'same_organization'
424
+ && externalIds.has(externalFrom.id)
425
+ && !externalIds.has(toEntity.id)
426
+ && toEntity.entity_type === 'app'
427
+ && toEntity.lifecycle_status === 'active';
428
+ if (!localFrom && !sameOrganizationToolAccountable)
429
+ throw new Error(`Unknown entity: ${operation.from_id}`);
430
+ const fromEntity = (localFrom || externalFrom);
431
+ // A project of this scope using a same-organization tool of another project.
432
+ const sameOrganizationProjectUsesApp = operation.rel_type === 'uses_app'
433
+ && Boolean(localFrom)
434
+ && fromEntity.entity_type === 'project'
435
+ && fromEntity.lifecycle_status === 'active'
436
+ && toEntity.entity_type === 'app'
437
+ && toEntity.lifecycle_status === 'active'
438
+ && toEntity.reference_scope === 'same_organization'
439
+ && externalIds.has(toEntity.id);
440
+ const sameOrganizationAppOwner = operation.rel_type === 'owned_by'
441
+ && fromEntity.entity_type === 'app'
442
+ && fromEntity.lifecycle_status === 'active'
443
+ && toEntity.entity_type === 'org'
444
+ && toEntity.lifecycle_status === 'active'
445
+ && toEntity.reference_scope === 'same_organization'
446
+ && (state.external_entities || []).some((entity) => entity.id === toEntity.id);
447
+ const sameOrganizationDecisionOwner = operation.rel_type === 'owned_by'
448
+ && isActiveDecision(fromEntity)
449
+ && toEntity.entity_type === 'person'
450
+ && toEntity.lifecycle_status === 'active'
451
+ && toEntity.reference_scope === 'same_organization'
452
+ && (state.external_entities || []).some((entity) => entity.id === toEntity.id);
453
+ if (sameOrganizationDecisionOwner && operation.decision_id !== fromEntity.id) {
454
+ // Bind this relationship mutation to the existing plan-level Human Gate.
455
+ throw new Error('decision_id must match the Decision owner source');
456
+ }
457
+ if (fromEntity.project_code !== toEntity.project_code && !sameOrganizationAppOwner && !sameOrganizationDecisionOwner
458
+ && !sameOrganizationToolAccountable && !sameOrganizationProjectUsesApp) {
459
+ throw new Error('edge endpoint project scope mismatch');
460
+ }
461
+ if (!String(operation.rel_type || '').trim())
462
+ throw new Error('rel_type is required');
463
+ const existing = state.edges.find((item) => item.from_id === operation.from_id && item.to_id === operation.to_id && item.rel_type === operation.rel_type);
464
+ const edgeById = operation.edge_id ? state.edges.find((item) => item.id === operation.edge_id) : null;
465
+ if (existing && operation.edge_id && existing.id !== operation.edge_id)
466
+ throw new Error('edge_id does not match existing edge');
467
+ if (!existing && edgeById)
468
+ throw new Error('edge id conflict');
469
+ if (existing) {
470
+ requireVersion(existing, operation);
471
+ assertSensitivity(existing.sensitivity, operation.sensitivity);
472
+ assertRoleMinimum(existing.role_min, operation.role_min);
473
+ existing.payload = { ...existing.payload, ...(operation.payload || {}) };
474
+ if (operation.role_min !== undefined)
475
+ existing.role_min = operation.role_min;
476
+ if (operation.sensitivity !== undefined)
477
+ existing.sensitivity = operation.sensitivity;
478
+ assertAccessMetadata(existing.role_min, existing.sensitivity);
479
+ existing.lifecycle_status = 'active';
480
+ existing.version += 1;
481
+ }
482
+ else {
483
+ if (operation.expected_version !== 0)
484
+ throw new Error('expected_version conflict');
485
+ if (!operation.edge_id)
486
+ throw new Error('edge_id is required');
487
+ const roleMin = operation.role_min || 'member';
488
+ const sensitivity = operation.sensitivity || 'internal';
489
+ assertAccessMetadata(roleMin, sensitivity);
490
+ state.edges.push({
491
+ id: operation.edge_id,
492
+ from_id: operation.from_id, to_id: operation.to_id, rel_type: operation.rel_type,
493
+ // The accountable person lives in another project; the edge belongs to the tool's project.
494
+ project_code: sameOrganizationToolAccountable ? toEntity.project_code : fromEntity.project_code,
495
+ payload: operation.payload || {}, role_min: roleMin,
496
+ sensitivity, lifecycle_status: 'active', version: 1
497
+ });
498
+ }
499
+ }
500
+ else if (operation.operation === 'materialize_project_subject') {
501
+ if (operation.expected_version !== 0)
502
+ throw new Error('expected_version conflict');
503
+ if (!String(operation.entity_id || '').trim())
504
+ throw new Error('entity_id is required');
505
+ if (operation.catalog_project_id !== operation.entity_id) {
506
+ throw new Error('Catalog Project ID must match Graph Entity ID');
507
+ }
508
+ if (!Number.isInteger(operation.catalog_version) || operation.catalog_version < 1) {
509
+ throw new Error('catalog_version must be a positive integer');
510
+ }
511
+ if (!String(operation.name || '').trim())
512
+ throw new Error('name is required');
513
+ const expectedSourceRef = `project-catalog:${operation.catalog_project_id}@${operation.catalog_version}`;
514
+ if (operation.source_ref !== expectedSourceRef)
515
+ throw new Error('source_ref must match Catalog Project identity and version');
516
+ if (findAnyEntityOrNull(state, operation.entity_id))
517
+ throw new Error('entity id conflict');
518
+ state.entities.push({
519
+ id: operation.entity_id,
520
+ entity_type: 'project',
521
+ project_code: state.project_code,
522
+ payload: {
523
+ name: operation.name,
524
+ catalog_project_id: operation.catalog_project_id,
525
+ catalog_version: operation.catalog_version,
526
+ source_ref: operation.source_ref,
527
+ ...(operation.kind ? { kind: operation.kind } : {}),
528
+ ...(operation.organization_entity_id
529
+ ? { organization_entity_id: operation.organization_entity_id } : {}),
530
+ ...(operation.owner_person_id ? { owner_person_id: operation.owner_person_id } : {})
531
+ },
532
+ role_min: 'member',
533
+ sensitivity: 'internal',
534
+ lifecycle_status: 'active',
535
+ version: 1
536
+ });
537
+ }
538
+ else if (operation.operation === 'link_decision_project_subject') {
539
+ const decision = findEntity(state, operation.decision_id);
540
+ const subject = findEntity(state, operation.subject_entity_id);
541
+ requireVersion(decision, { expected_version: operation.decision_expected_version });
542
+ requireVersion(subject, { expected_version: operation.subject_expected_version });
543
+ if (decision.entity_type !== 'decision' || decision.lifecycle_status !== 'active')
544
+ throw new Error('active Decision source is required');
545
+ if (subject.entity_type !== 'project' || subject.lifecycle_status !== 'active')
546
+ throw new Error('active Project subject is required');
547
+ assertProjectCatalogProvenance(subject);
548
+ if (decision.project_code !== state.project_code || subject.project_code !== state.project_code) {
549
+ throw new Error('Decision project subject scope mismatch');
550
+ }
551
+ if (!(operation.human_gate_receipt || humanGateReceipt)) {
552
+ throw new Error('human_gate_receipt is required for Decision project subject link');
553
+ }
554
+ if (operation.expected_version !== 0)
555
+ throw new Error('expected_version conflict');
556
+ if (!String(operation.edge_id || '').trim())
557
+ throw new Error('edge_id is required');
558
+ if (state.edges.some((edge) => edge.id === operation.edge_id))
559
+ throw new Error('edge id conflict');
560
+ if (state.edges.some((edge) => edge.from_id === decision.id && edge.to_id === subject.id && edge.rel_type === 'governs')) {
561
+ throw new Error('Decision project subject edge already exists');
562
+ }
563
+ state.edges.push({
564
+ id: operation.edge_id,
565
+ from_id: decision.id,
566
+ to_id: subject.id,
567
+ rel_type: 'governs',
568
+ project_code: decision.project_code,
569
+ payload: { catalog_project_id: subject.payload.catalog_project_id },
570
+ role_min: 'ceo',
571
+ sensitivity: 'restricted',
572
+ lifecycle_status: 'active',
573
+ version: 1
574
+ });
575
+ }
576
+ else if (operation.operation === 'link_decision_subject') {
577
+ const decision = findEntity(state, operation.decision_id);
578
+ const subject = findAnyEntity(state, operation.subject_entity_id);
579
+ requireVersion(decision, { expected_version: operation.decision_expected_version });
580
+ requireVersion(subject, { expected_version: operation.subject_expected_version });
581
+ if (decision.entity_type !== 'decision' || decision.lifecycle_status !== 'active')
582
+ throw new Error('active Decision source is required');
583
+ if (subject.entity_type !== 'product' || subject.lifecycle_status !== 'active')
584
+ throw new Error('active Product subject is required');
585
+ if (decision.project_code !== state.project_code)
586
+ throw new Error('Decision source scope mismatch');
587
+ if (!String(operation.target_project_code || '').trim() || subject.project_code !== operation.target_project_code) {
588
+ throw new Error('Product target scope mismatch');
589
+ }
590
+ if (decision.project_code === subject.project_code)
591
+ throw new Error('link_decision_subject requires cross-project endpoints');
592
+ if (!(operation.human_gate_receipt || humanGateReceipt))
593
+ throw new Error('human_gate_receipt is required for Decision subject link');
594
+ if (operation.expected_version !== 0)
595
+ throw new Error('expected_version conflict');
596
+ if (!String(operation.edge_id || '').trim())
597
+ throw new Error('edge_id is required');
598
+ if (state.edges.some((edge) => edge.id === operation.edge_id))
599
+ throw new Error('edge id conflict');
600
+ if (state.edges.some((edge) => edge.from_id === decision.id && edge.to_id === subject.id && edge.rel_type === 'governs')) {
601
+ throw new Error('Decision subject edge already exists');
602
+ }
603
+ state.edges.push({
604
+ id: operation.edge_id,
605
+ from_id: decision.id,
606
+ to_id: subject.id,
607
+ rel_type: 'governs',
608
+ project_code: decision.project_code,
609
+ payload: { ...(operation.payload || {}), target_project_code: subject.project_code, cross_tenant: true },
610
+ role_min: 'ceo',
611
+ sensitivity: 'restricted',
612
+ lifecycle_status: 'active',
613
+ version: 1
614
+ });
615
+ }
616
+ else if (operation.operation === 'add_shared_membership') {
617
+ addSharedMembership(state, operation, humanGateReceipt);
618
+ }
619
+ else if (operation.operation === 'retire_edge') {
620
+ const edge = findEdge(state, operation);
621
+ requireVersion(edge, operation);
622
+ edge.lifecycle_status = 'retired';
623
+ edge.version += 1;
624
+ }
625
+ else if (operation.operation === 'normalize_alias') {
626
+ const entity = findEntity(state, operation.entity_id);
627
+ requireVersion(entity, operation);
628
+ const aliases = Array.isArray(operation.aliases) ? operation.aliases : entity.payload?.aliases || [];
629
+ entity.payload = { ...entity.payload, aliases: [...new Set(aliases.map((item) => String(item).trim()).filter(Boolean))].sort() };
630
+ entity.version += 1;
631
+ }
632
+ }
633
+ const finalValidation = validateGraphSnapshot(state);
634
+ const introducedIssues = introducedValidationIssues(initialValidation, finalValidation);
635
+ if (introducedIssues.length) {
636
+ throw validationError({ issues: introducedIssues }, 'Graph operations introduced invalid state');
637
+ }
638
+ state.hash = hashGraphSnapshot(state);
639
+ return state;
640
+ }
641
+ export function buildGraphPlan(snapshot, input = {}) {
642
+ if (!String(input.reason || '').trim())
643
+ throw new Error('reason is required');
644
+ if (!String(input.idempotency_key || '').trim())
645
+ throw new Error('idempotency_key is required');
646
+ if (snapshot.project_code !== input.project_code)
647
+ throw new Error('project scope mismatch');
648
+ if (snapshot.hash && snapshot.hash !== hashGraphSnapshot(snapshot))
649
+ throw new Error('snapshot hash mismatch');
650
+ const operations = Array.isArray(input.operations) ? input.operations : [];
651
+ const max = Math.min(Number(input.max_operations) || GRAPH_MAINTENANCE_MAX_OPERATIONS, GRAPH_MAINTENANCE_MAX_OPERATIONS);
652
+ if (operations.length > max)
653
+ throw new Error('bulk operation limit exceeded');
654
+ const before = structuredClone(snapshot);
655
+ before.hash = hashGraphSnapshot(before);
656
+ const after = applyGraphOperations(before, operations, { projectCode: input.project_code, humanGateReceipt: input.human_gate_receipt });
657
+ const validation = validateGraphSnapshot(after);
658
+ return {
659
+ dry_run: true,
660
+ project_code: input.project_code,
661
+ idempotency_key: input.idempotency_key,
662
+ reason: input.reason.trim(),
663
+ operations,
664
+ operation_count: operations.length,
665
+ before_hash: before.hash,
666
+ after_hash: after.hash,
667
+ validation,
668
+ before,
669
+ after
670
+ };
671
+ }
672
+ export function validateGraphSnapshot(snapshot) {
673
+ const issues = [];
674
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
675
+ return { valid: false, counts: { entities: 0, edges: 0, issues: 1, duplicates: 0, orphans: 0 }, issues: [{ category: 'snapshot' }] };
676
+ }
677
+ if (!String(snapshot.project_code || '').trim())
678
+ issues.push({ category: 'project_scope' });
679
+ if (!Array.isArray(snapshot.entities))
680
+ issues.push({ category: 'entities' });
681
+ if (!Array.isArray(snapshot.edges))
682
+ issues.push({ category: 'edges' });
683
+ const entityIds = new Set();
684
+ const entitiesById = new Map();
685
+ const externalEntityIds = new Set((Array.isArray(snapshot.external_entities) ? snapshot.external_entities : [])
686
+ .map((entity) => entity?.id)
687
+ .filter(Boolean));
688
+ const allEntities = [
689
+ ...(Array.isArray(snapshot.entities) ? snapshot.entities : []),
690
+ ...(Array.isArray(snapshot.external_entities) ? snapshot.external_entities : [])
691
+ ];
692
+ for (const entity of allEntities) {
693
+ if (!entity || typeof entity !== 'object' || Array.isArray(entity)) {
694
+ issues.push({ category: 'entity' });
695
+ continue;
696
+ }
697
+ if (!String(entity.id || '').trim())
698
+ issues.push({ category: 'entity_id', id: entity.id });
699
+ if (entityIds.has(entity.id))
700
+ issues.push({ category: 'duplicate_entity', id: entity.id });
701
+ entityIds.add(entity.id);
702
+ entitiesById.set(entity.id, entity);
703
+ if (!Number.isInteger(entity.version) || entity.version < 1)
704
+ issues.push({ category: 'version', id: entity.id });
705
+ if (!String(entity.entity_type || '').trim())
706
+ issues.push({ category: 'entity_type', id: entity.id });
707
+ if (!String(entity.project_code || '').trim())
708
+ issues.push({ category: 'project_scope', id: entity.id });
709
+ if (!(entity.role_min in ROLE_RANK))
710
+ issues.push({ category: 'role_min', id: entity.id });
711
+ if (!(entity.sensitivity in SENSITIVITY_RANK))
712
+ issues.push({ category: 'sensitivity', id: entity.id });
713
+ if (!LIFECYCLE_STATUSES.has(entity.lifecycle_status))
714
+ issues.push({ category: 'lifecycle', id: entity.id });
715
+ // Cross-tenant endpoints are deliberately represented by metadata only.
716
+ // Requiring payload here would either invalidate every safe plan or copy
717
+ // target-tenant business data into the source-tenant receipt.
718
+ if (!externalEntityIds.has(entity.id)
719
+ && (!entity.payload || typeof entity.payload !== 'object' || Array.isArray(entity.payload))) {
720
+ issues.push({ category: 'payload', id: entity.id });
721
+ }
722
+ if (SENSITIVITY_RANK[entity.sensitivity] >= SENSITIVITY_RANK.finance && entity.role_min === 'member') {
723
+ issues.push({ category: 'sensitivity_role', id: entity.id });
724
+ }
725
+ }
726
+ const edgeKeys = new Set();
727
+ const edgeIds = new Set();
728
+ for (const edge of Array.isArray(snapshot.edges) ? snapshot.edges : []) {
729
+ if (!edge || typeof edge !== 'object' || Array.isArray(edge)) {
730
+ issues.push({ category: 'edge' });
731
+ continue;
732
+ }
733
+ if (!String(edge.id || '').trim())
734
+ issues.push({ category: 'edge_id', id: edge.id });
735
+ if (edgeIds.has(edge.id))
736
+ issues.push({ category: 'duplicate_edge_id', id: edge.id });
737
+ edgeIds.add(edge.id);
738
+ if (!Number.isInteger(edge.version) || edge.version < 1)
739
+ issues.push({ category: 'edge_version', id: edge.id });
740
+ if (!String(edge.rel_type || '').trim())
741
+ issues.push({ category: 'rel_type', id: edge.id });
742
+ if (!String(edge.project_code || '').trim())
743
+ issues.push({ category: 'project_scope', id: edge.id });
744
+ if (!(edge.role_min in ROLE_RANK))
745
+ issues.push({ category: 'role_min', id: edge.id });
746
+ if (!(edge.sensitivity in SENSITIVITY_RANK))
747
+ issues.push({ category: 'sensitivity', id: edge.id });
748
+ if (!LIFECYCLE_STATUSES.has(edge.lifecycle_status))
749
+ issues.push({ category: 'lifecycle', id: edge.id });
750
+ if (!edge.payload || typeof edge.payload !== 'object' || Array.isArray(edge.payload))
751
+ issues.push({ category: 'payload', id: edge.id });
752
+ if (SENSITIVITY_RANK[edge.sensitivity] >= SENSITIVITY_RANK.finance && edge.role_min === 'member') {
753
+ issues.push({ category: 'sensitivity_role', id: edge.id });
754
+ }
755
+ if (!entityIds.has(edge.from_id) || !entityIds.has(edge.to_id))
756
+ issues.push({ category: 'orphan', id: edge.id });
757
+ if (edge.rel_type === 'belongs_to_project' && edge.lifecycle_status === 'active') {
758
+ const source = entitiesById.get(edge.from_id);
759
+ const target = entitiesById.get(edge.to_id);
760
+ if (source && target && (target.entity_type !== 'project'
761
+ || source.project_code !== edge.project_code
762
+ || target.project_code !== edge.project_code)) {
763
+ issues.push({ category: 'membership_scope', id: edge.id });
764
+ }
765
+ }
766
+ const source = entitiesById.get(edge.from_id);
767
+ const target = entitiesById.get(edge.to_id);
768
+ const targetIsCrossTenant = externalEntityIds.has(target?.id)
769
+ && target.reference_scope !== 'same_organization';
770
+ if (source && target
771
+ && (targetIsCrossTenant || edge.payload?.cross_tenant === true)
772
+ && source.project_code !== target.project_code) {
773
+ const canonicalCrossTenant = edge.rel_type === 'governs'
774
+ && edge.project_code === source.project_code
775
+ && edge.payload?.cross_tenant === true
776
+ && edge.payload?.target_project_code === target.project_code
777
+ && edge.role_min === 'ceo'
778
+ && edge.sensitivity === 'restricted';
779
+ if (!canonicalCrossTenant
780
+ || source.entity_type !== 'decision'
781
+ || source.lifecycle_status !== 'active'
782
+ || target.entity_type !== 'product'
783
+ || target.reference_scope === 'same_organization'
784
+ || target.lifecycle_status !== 'active') {
785
+ issues.push({ category: 'cross_tenant_edge', id: edge.id });
786
+ }
787
+ }
788
+ const key = `${edge.from_id}\u0000${edge.to_id}\u0000${edge.rel_type}`;
789
+ if (edgeKeys.has(key))
790
+ issues.push({ category: 'duplicate_edge', id: edge.id });
791
+ edgeKeys.add(key);
792
+ }
793
+ return {
794
+ valid: issues.length === 0,
795
+ counts: {
796
+ entities: Array.isArray(snapshot.entities) ? snapshot.entities.length : 0,
797
+ edges: Array.isArray(snapshot.edges) ? snapshot.edges.length : 0,
798
+ issues: issues.length,
799
+ duplicates: issues.filter((item) => item.category.startsWith('duplicate')).length,
800
+ orphans: issues.filter((item) => item.category === 'orphan').length
801
+ },
802
+ issues
803
+ };
804
+ }