@unson/brainbase-mcp 0.31.0 → 0.33.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,44 @@
1
+ export type GraphRecord = Record<string, any>;
2
+ export type GraphEntity = GraphRecord;
3
+ export type GraphEdge = GraphRecord;
4
+ export type GraphSnapshot = GraphRecord;
5
+ export type GraphOperation = GraphRecord;
6
+ export type GraphValidationIssue = {
7
+ category: string;
8
+ id?: unknown;
9
+ };
10
+ export type GraphValidation = {
11
+ valid: boolean;
12
+ counts: {
13
+ entities: number;
14
+ edges: number;
15
+ issues: number;
16
+ duplicates: number;
17
+ orphans: number;
18
+ };
19
+ issues: GraphValidationIssue[];
20
+ };
21
+ export type GraphPlanInput = GraphRecord;
22
+ export type GraphPlan = {
23
+ dry_run: true;
24
+ project_code: any;
25
+ idempotency_key: any;
26
+ reason: string;
27
+ operations: GraphOperation[];
28
+ operation_count: number;
29
+ before_hash: string;
30
+ after_hash: string;
31
+ validation: GraphValidation;
32
+ before: GraphSnapshot;
33
+ after: GraphSnapshot;
34
+ };
35
+ export declare const GRAPH_MAINTENANCE_OPERATIONS: readonly string[];
36
+ export declare const GRAPH_MAINTENANCE_MAX_OPERATIONS = 100;
37
+ export declare function hashGraphSnapshot(snapshot: GraphSnapshot): string;
38
+ export declare function findIntroducedGraphValidationIssues(beforeSnapshot: GraphSnapshot, afterSnapshot: GraphSnapshot): GraphValidationIssue[];
39
+ export declare function applyGraphOperations(snapshot: GraphSnapshot, operations: GraphOperation[], { projectCode, humanGateReceipt }?: {
40
+ projectCode?: unknown;
41
+ humanGateReceipt?: unknown;
42
+ }): GraphSnapshot;
43
+ export declare function buildGraphPlan(snapshot: GraphSnapshot, input?: GraphPlanInput): GraphPlan;
44
+ export declare function validateGraphSnapshot(snapshot: any): GraphValidation;
@@ -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
+ }
@@ -0,0 +1,43 @@
1
+ export type MemoryRecordInput = Record<string, any> | null | undefined;
2
+ export type MemoryAccessContextInput = Record<string, any>;
3
+ export type MemoryAccessContext = {
4
+ person_id: string | null;
5
+ workspace: string | null;
6
+ channel_id: string | null;
7
+ session_id: string | null;
8
+ roles: string[];
9
+ role_rank: number;
10
+ project_codes: string[];
11
+ clearance: string[];
12
+ };
13
+ export type NormalizedMemoryRecord = {
14
+ id: string | null;
15
+ visibility: string;
16
+ owner_person_id: string | null;
17
+ workspace: string | null;
18
+ channel_id: string | null;
19
+ project_code: string | null;
20
+ role_min: string;
21
+ sensitivity: string;
22
+ };
23
+ export type MemoryScopeEvaluation = {
24
+ id: string | null;
25
+ visibility: string;
26
+ reason: string | null;
27
+ allowed: boolean;
28
+ };
29
+ export type ScopedMemoryResult<T> = {
30
+ mode: 'deny_by_default';
31
+ records: T[];
32
+ denied: MemoryScopeEvaluation[];
33
+ meta: {
34
+ evaluated_count: number;
35
+ returned_count: number;
36
+ denied_count: number;
37
+ };
38
+ };
39
+ export declare function normalizeMemoryAccessContext(input?: MemoryAccessContextInput): MemoryAccessContext;
40
+ export declare function normalizeMemoryRecord(record: MemoryRecordInput): NormalizedMemoryRecord;
41
+ export declare function evaluateMemoryScope(record: MemoryRecordInput, rawContext?: MemoryAccessContextInput): MemoryScopeEvaluation;
42
+ export declare function buildScopedMemoryResult<T extends MemoryRecordInput>(records?: T[], context?: MemoryAccessContextInput): ScopedMemoryResult<T>;
43
+ export declare function filterScopedMemoryRecords<T extends MemoryRecordInput>(records?: T[], context?: MemoryAccessContextInput): T[];
@@ -0,0 +1,159 @@
1
+ // Moved from brainbase-unson server/services/memory-scope-policy.js without changing behavior.
2
+ // Only type annotations were added; memory records stay open JSON objects as in the original.
3
+ /* eslint-disable @typescript-eslint/no-explicit-any */
4
+ const ROLE_RANK = {
5
+ member: 1,
6
+ gm: 2,
7
+ ceo: 3
8
+ };
9
+ function toList(value) {
10
+ if (Array.isArray(value)) {
11
+ return value.map(item => String(item).trim()).filter(Boolean);
12
+ }
13
+ if (typeof value === 'string') {
14
+ return value.split(',').map(item => item.trim()).filter(Boolean);
15
+ }
16
+ return [];
17
+ }
18
+ function firstString(...values) {
19
+ for (const value of values) {
20
+ if (typeof value === 'string' && value.trim())
21
+ return value.trim();
22
+ }
23
+ return null;
24
+ }
25
+ function parsePayload(record) {
26
+ if (!record || typeof record !== 'object')
27
+ return {};
28
+ const payload = record.payload;
29
+ if (typeof payload === 'string') {
30
+ try {
31
+ return JSON.parse(payload);
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ }
37
+ if (payload && typeof payload === 'object') {
38
+ return payload;
39
+ }
40
+ return record;
41
+ }
42
+ function normalizeRole(role) {
43
+ return typeof role === 'string' ? role.trim().toLowerCase() : '';
44
+ }
45
+ function maxRoleRank(roles) {
46
+ return roles.reduce((rank, role) => Math.max(rank, ROLE_RANK[normalizeRole(role)] || 0), 0);
47
+ }
48
+ export function normalizeMemoryAccessContext(input = {}) {
49
+ const roles = [
50
+ ...toList(input.roles),
51
+ ...toList(input.role)
52
+ ].map(normalizeRole).filter(Boolean);
53
+ const projectCodes = [
54
+ ...toList(input.project_codes),
55
+ ...toList(input.projectCodes)
56
+ ];
57
+ const clearance = [
58
+ ...toList(input.clearance),
59
+ ...toList(input.sensitivities),
60
+ ...toList(input.sensitivity)
61
+ ].map(item => item.toLowerCase());
62
+ return {
63
+ person_id: firstString(input.person_id, input.personId),
64
+ workspace: firstString(input.workspace),
65
+ channel_id: firstString(input.channel_id, input.channelId),
66
+ session_id: firstString(input.session_id, input.sessionId),
67
+ roles,
68
+ role_rank: maxRoleRank(roles),
69
+ project_codes: Array.from(new Set(projectCodes)),
70
+ clearance: Array.from(new Set(clearance))
71
+ };
72
+ }
73
+ export function normalizeMemoryRecord(record) {
74
+ const payload = parsePayload(record);
75
+ return {
76
+ id: firstString(payload.memory_candidate_id, payload.candidate_id, record?.id),
77
+ visibility: firstString(payload.visibility) || 'unknown',
78
+ owner_person_id: firstString(payload.owner_person_id, payload.ownerPersonId),
79
+ workspace: firstString(payload.workspace, record?.workspace),
80
+ channel_id: firstString(payload.channel_id, payload.channelId, record?.channel_id, record?.channelId),
81
+ project_code: firstString(payload.project_code, payload.projectCode, record?.project_code, record?.projectCode),
82
+ role_min: normalizeRole(firstString(payload.role_min, payload.roleMin, record?.role_min, record?.roleMin) || 'member'),
83
+ sensitivity: firstString(payload.sensitivity, record?.sensitivity) || 'internal'
84
+ };
85
+ }
86
+ function hasRequiredPolicy(context) {
87
+ return Boolean(context.person_id
88
+ && context.roles.length > 0
89
+ && context.project_codes.length > 0
90
+ && context.clearance.length > 0);
91
+ }
92
+ export function evaluateMemoryScope(record, rawContext = {}) {
93
+ const context = normalizeMemoryAccessContext(rawContext);
94
+ const memory = normalizeMemoryRecord(record);
95
+ const resultBase = {
96
+ id: memory.id,
97
+ visibility: memory.visibility,
98
+ reason: null
99
+ };
100
+ if (!hasRequiredPolicy(context)) {
101
+ return { ...resultBase, allowed: false, reason: 'missing_access_policy' };
102
+ }
103
+ if (!memory.id || memory.visibility === 'unknown') {
104
+ return { ...resultBase, allowed: false, reason: 'invalid_memory_record' };
105
+ }
106
+ if (memory.workspace && context.workspace && memory.workspace !== context.workspace) {
107
+ return { ...resultBase, allowed: false, reason: 'workspace_mismatch' };
108
+ }
109
+ if (memory.project_code && !context.project_codes.includes(memory.project_code)) {
110
+ return { ...resultBase, allowed: false, reason: 'project_mismatch' };
111
+ }
112
+ if (memory.channel_id && context.channel_id && memory.channel_id !== context.channel_id) {
113
+ return { ...resultBase, allowed: false, reason: 'channel_mismatch' };
114
+ }
115
+ const sensitivity = memory.sensitivity.toLowerCase();
116
+ if (!context.clearance.includes(sensitivity)) {
117
+ return { ...resultBase, allowed: false, reason: 'sensitivity_denied' };
118
+ }
119
+ if (context.role_rank < (ROLE_RANK[memory.role_min] || ROLE_RANK.member)) {
120
+ return { ...resultBase, allowed: false, reason: 'role_denied' };
121
+ }
122
+ if (memory.visibility === 'private' || memory.visibility === 'owner') {
123
+ const elevatedContext = context.roles.includes('gm') || context.roles.includes('ceo');
124
+ if (memory.owner_person_id !== context.person_id || elevatedContext) {
125
+ return { ...resultBase, allowed: false, reason: 'private_scope_denied' };
126
+ }
127
+ return { ...resultBase, allowed: true };
128
+ }
129
+ if (memory.visibility === 'project' || memory.visibility === 'role' || memory.visibility === 'team' || memory.visibility === 'org' || memory.visibility === 'public') {
130
+ return { ...resultBase, allowed: true };
131
+ }
132
+ return { ...resultBase, allowed: false, reason: 'visibility_denied' };
133
+ }
134
+ export function buildScopedMemoryResult(records = [], context = {}) {
135
+ const allowed = [];
136
+ const denied = [];
137
+ for (const record of records) {
138
+ const evaluation = evaluateMemoryScope(record, context);
139
+ if (evaluation.allowed) {
140
+ allowed.push(record);
141
+ }
142
+ else {
143
+ denied.push(evaluation);
144
+ }
145
+ }
146
+ return {
147
+ mode: 'deny_by_default',
148
+ records: allowed,
149
+ denied,
150
+ meta: {
151
+ evaluated_count: records.length,
152
+ returned_count: allowed.length,
153
+ denied_count: denied.length
154
+ }
155
+ };
156
+ }
157
+ export function filterScopedMemoryRecords(records = [], context = {}) {
158
+ return buildScopedMemoryResult(records, context).records;
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.31.0",
3
+ "version": "0.33.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",
@@ -187,6 +187,14 @@
187
187
  "types": "./dist/judgment-resolution-contract.d.ts",
188
188
  "import": "./dist/judgment-resolution-contract.js"
189
189
  },
190
+ "./graph-maintenance-engine": {
191
+ "types": "./dist/graph-maintenance-engine.d.ts",
192
+ "import": "./dist/graph-maintenance-engine.js"
193
+ },
194
+ "./memory-scope-policy": {
195
+ "types": "./dist/memory-scope-policy.d.ts",
196
+ "import": "./dist/memory-scope-policy.js"
197
+ },
190
198
  "./canonical-document-writer": {
191
199
  "types": "./dist/canonical-document-writer.d.ts",
192
200
  "import": "./dist/canonical-document-writer.js"
@@ -455,5 +463,5 @@
455
463
  "engines": {
456
464
  "node": ">=20"
457
465
  },
458
- "gitHead": "aeb8467e65eda4223dd092cc43d69b4ebd040f42"
466
+ "gitHead": "c3c5a1b3e4d5de936ecebc7ab86346bfdd49faf8"
459
467
  }
@@ -146,6 +146,14 @@
146
146
  font-weight: 700;
147
147
  }
148
148
  .bb-world-canvas-ui .bb-world-canvas-quest-text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
149
+ /* A quest with a way down (候補の確認) is clickable even though the quest list lets the map take the pointer. */
150
+ .bb-world-canvas-ui .bb-world-canvas-quest a {
151
+ flex: none;
152
+ color: #b5f2ab;
153
+ text-decoration: underline;
154
+ text-underline-offset: 2px;
155
+ pointer-events: auto;
156
+ }
149
157
 
150
158
  .bb-world-canvas-ui .bb-world-canvas-edge {
151
159
  position: absolute;
@@ -357,13 +357,14 @@ export function createWorldCanvasUI({ doc, stage, reducedMotion = false, onReset
357
357
  activate('help', help);
358
358
  focus(helpTitle);
359
359
  }
360
- function addNote({ label = '', text = '', tone = '', summary } = {}) {
360
+ function addNote({ label = '', text = '', tone = '', summary, link = null } = {}) {
361
361
  if (disposed) return;
362
362
  const note = element('section', 'bb-world-canvas-note');
363
363
  if (label) note.append(element('h3', '', label));
364
364
  if (text || summary) note.append(element('p', '', text || summary));
365
365
  if (label || text || summary) notes.append(note);
366
366
  if (typeof summary !== 'string' || !summary.trim()) return;
367
+ // A link (already checked by the caller) follows the summary so the quest is a way down, not only a sign.
367
368
  const quest = element('p', 'bb-world-canvas-quest');
368
369
  const icon = tone === 'attention' || tone === 'warning' ? '!' : 'i';
369
370
  quest.append(
@@ -373,6 +374,7 @@ export function createWorldCanvasUI({ doc, stage, reducedMotion = false, onReset
373
374
  // The accessible name contains the unabridged summary even when visual text
374
375
  // is ellipsized on a narrow canvas; the long explanation remains in help.
375
376
  quest.setAttribute('aria-label', summary.trim());
377
+ if (link?.href) quest.append(element('a', link.className ?? '', link.label ?? summary.trim(), { href: link.href }));
376
378
  quests.append(quest);
377
379
  }
378
380
  function clearNotes() {
@@ -40,6 +40,7 @@
40
40
  }
41
41
 
42
42
  .bb-world-hud-line.is-attention { border-left-color: var(--bb-color-danger); }
43
+ .bb-world-hud-line a { margin-left: 0.5em; color: inherit; text-decoration: underline; }
43
44
  .bb-world-hud-line.is-warning {
44
45
  border-left-color: var(--bb-color-warning);
45
46
  background: rgb(255 240 199 / 95%);
@@ -33,7 +33,7 @@ import { createWorldCanvasUI } from './world-canvas-ui.js';
33
33
  import { workCanvasNotes } from './world-canvas-notes.js';
34
34
  import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, muted, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
35
35
  import { WORK_STATES, workCityBlocks, workGrowthBlock, workPickBlock, workSign, workSiteBlocks } from './world-work-rail.js';
36
- import { exitLegend, exitsRailBlock, normalizeBusinessExits } from './world-exits.js';
36
+ import { exitHrefKind, exitLegend, exitsRailBlock, normalizeBusinessExits } from './world-exits.js';
37
37
  import {
38
38
  makeWorkspaceElement as el,
39
39
  workspacePageHeader,
@@ -1462,13 +1462,85 @@ function addressSelection() {
1462
1462
  * label?, as_of? } }] }`. `href` and `action.href` must be `https:` or a link inside the host (`/…`, `?…`);
1463
1463
  * others are dropped and counted. Without it the world is drawn exactly as before.
1464
1464
  * @typedef {{ status: string, read_at?: string, reason?: string, exits: object[] }} BusinessExitsAnswer
1465
+ * @param {() => (MemoryStatusAnswer | Promise<MemoryStatusAnswer>)} [options.memoryStatus] How the memory loop stands
1466
+ * (story-world-memory-status-v1, ledger P18): last night's organizing and the memory candidates that wait for the
1467
+ * owner. The answer is `{ status: 'available'|'partial'|'not_connected', night?: { status:
1468
+ * 'completed'|'partial'|'failed'|'missing', finished_at? } | null, candidates?: { count } | null, review_href? }`.
1469
+ * A part that is null was not read and is shown as unknown, never as zero. Without it the world says 未接続.
1470
+ * @typedef {{ status: string, night?: object | null, candidates?: object | null, review_href?: string }} MemoryStatusAnswer
1465
1471
  */
1472
+ async function readMemoryStatus(memoryStatus) {
1473
+ if (typeof memoryStatus !== 'function') return { status: 'not_connected' };
1474
+ try {
1475
+ const answer = await memoryStatus();
1476
+ return answer && typeof answer === 'object' ? answer : { status: 'failed', reason: 'invalid_answer' };
1477
+ } catch (error) {
1478
+ return { status: 'failed', reason: error instanceof Error ? error.message : 'request_failed' };
1479
+ }
1480
+ }
1481
+
1482
+ const NIGHT_STATES = {
1483
+ completed: { text: '済み', tone: 'info' },
1484
+ partial: { text: '一部だけ(確かめられていない範囲があります)', tone: 'warning' },
1485
+ failed: { text: 'できなかった(整理できなかった夜)', tone: 'attention' },
1486
+ missing: { text: '動いていない(整理できなかった夜)', tone: 'attention' },
1487
+ };
1488
+
1489
+ /** The memory loop as world notes (story-world-memory-status-v1). Unknown is never shown as zero. */
1490
+ function memoryLines(answer) {
1491
+ if (answer.status === 'not_connected') {
1492
+ return [{ text: '記憶の整理:未接続。この画面は夜の整理の結果と記憶の候補を受け取れません。候補の件数は0件とは確認できません。', tone: 'info', summary: '記憶の整理:未接続' }];
1493
+ }
1494
+ if (answer.status !== 'available' && answer.status !== 'partial') {
1495
+ return [{ text: `記憶の整理:読めない(${answer.reason ?? '理由不明'})。候補の件数は0件とは確認できません。`, tone: 'warning', summary: '記憶の整理:読めない' }];
1496
+ }
1497
+ const lines = [];
1498
+ const night = answer.night && NIGHT_STATES[answer.night.status];
1499
+ if (night) {
1500
+ const label = `昨夜の記憶の整理:${night.text}`;
1501
+ lines.push({ text: `${label}。`, tone: night.tone, summary: night.tone === 'attention' ? '整理できなかった夜' : label });
1502
+ } else {
1503
+ lines.push({ text: '昨夜の記憶の整理:読めない。', tone: 'warning', summary: '昨夜の記憶の整理:読めない' });
1504
+ }
1505
+ const count = answer.candidates?.count;
1506
+ if (Number.isInteger(count) && count >= 0) {
1507
+ const href = typeof answer.review_href === 'string' && exitHrefKind(answer.review_href) ? answer.review_href : null;
1508
+ const link = href && count > 0 ? { href, label: '候補の確認を開く', className: 'bb-world-memory-link' } : null;
1509
+ if (count > 0) lines.push({ text: `あなたが決める記憶の候補 ${count}件。昇格したものだけが正本になります。`, tone: 'attention', summary: `あなたが決める記憶の候補 ${count}件`, link });
1510
+ else lines.push({ text: 'あなたが決める記憶の候補はありません。', tone: 'info', summary: null });
1511
+ } else {
1512
+ lines.push({ text: '記憶の候補:読めない。件数は0件とは確認できません。', tone: 'warning', summary: '記憶の候補:読めない' });
1513
+ }
1514
+ return lines;
1515
+ }
1516
+
1517
+ /** At most this many waiting judgments are listed in the world; the rest are counted, never hidden. */
1518
+ const WAITING_LIST_LIMIT = 3;
1519
+
1520
+ /**
1521
+ * The judgments waiting for the owner now, newest first, each a way down to the same judgment in
1522
+ * 判断の履歴 (ledger P17 / U2). The world stays an overview: only the exceptions become a short list.
1523
+ */
1524
+ function waitingLines(proofs, judgmentHref) {
1525
+ const waiting = [...proofs.values()]
1526
+ .filter(isWaiting)
1527
+ .sort((a, b) => String(b.proof?.recorded_at ?? '').localeCompare(String(a.proof?.recorded_at ?? '')));
1528
+ const lines = waiting.slice(0, WAITING_LIST_LIMIT).map((item) => {
1529
+ const id = item.proof.decision_attempt_id;
1530
+ const question = item.proof.interruption?.question_display_text ?? item.proof.decision?.summary ?? id;
1531
+ return { text: question, summary: question, link: { href: judgmentHref(id), label: '「判断の履歴」で開く', className: 'bb-world-waiting-link' } };
1532
+ });
1533
+ const rest = waiting.length - lines.length;
1534
+ if (rest > 0) lines.push({ text: `ほか${rest}件は「判断の履歴」で見られます。`, summary: `ほか${rest}件の待っている判断`, link: null });
1535
+ return lines;
1536
+ }
1537
+
1466
1538
  /** 判断の履歴 in the local web (it was 今日, `#today`, until 2026-10-11; the shell still opens old links). */
1467
1539
  function defaultJudgmentHref(decisionAttemptId) {
1468
1540
  return decisionAttemptId ? `#value-proofs?decision=${encodeURIComponent(decisionAttemptId)}` : '#value-proofs';
1469
1541
  }
1470
1542
 
1471
- export function createWorldView({ root, rail, page, presentation = 'standard', document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, judgmentHref = defaultJudgmentHref, selection = addressSelection(), businessExits = null }) {
1543
+ export function createWorldView({ root, rail, page, presentation = 'standard', document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, judgmentHref = defaultJudgmentHref, selection = addressSelection(), businessExits = null, memoryStatus = null }) {
1472
1544
  const exitsEnabled = typeof businessExits === 'function';
1473
1545
  const doc = explicitDocument ?? globalThis.document;
1474
1546
  const reducedMotion = Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
@@ -2311,6 +2383,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2311
2383
  }
2312
2384
 
2313
2385
  async function load() {
2386
+ const memoryRead = readMemoryStatus(memoryStatus);
2314
2387
  const businessesResult = await readJson(fetcher, '/api/extensions/world/businesses');
2315
2388
  if (disposed) return;
2316
2389
  // A world drawn from an organization's Graph shows only that organization's judgments: the host keeps
@@ -2333,13 +2406,14 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2333
2406
  }
2334
2407
  if (disposed) return;
2335
2408
  notices.replaceChildren();
2336
- const note = (label, textValue, tone = 'info', summary = null) => {
2409
+ const note = (label, textValue, tone = 'info', summary = null, link = null) => {
2337
2410
  if (canvasUI) {
2338
- setCanvasNote(`${label}:${summary ?? 'detail'}`, { label, text: textValue, tone, summary });
2411
+ setCanvasNote(`${label}:${summary ?? 'detail'}`, { label, text: textValue, tone, summary, link });
2339
2412
  return;
2340
2413
  }
2341
2414
  const line = el(doc, 'p', { className: `bb-world-hud-line is-${tone}`, attrs: tone === 'info' ? {} : { role: 'alert' } });
2342
2415
  line.append(el(doc, 'strong', { text: label }), el(doc, 'span', { text: textValue }));
2416
+ if (link) line.append(el(doc, 'a', { className: link.className, text: link.label, attrs: { href: link.href } }));
2343
2417
  notices.append(line);
2344
2418
  };
2345
2419
  const businessPayload = businessesResult.ok ? businessesResult.data : null;
@@ -2386,7 +2460,13 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2386
2460
  note('判断', `${home.delegation_map.judged}件。作業した場所を読めないため、事業には置かず広場の種類別だけで描いています(${placesResult.ok ? placesResult.data?.reason ?? '理由不明' : placesResult.error})。今あなたを待っている判断${waiting}件。`, 'warning', '判断の作業場所:読めない');
2387
2461
  }
2388
2462
  }
2463
+ if (judgmentsConnected) {
2464
+ for (const line of waitingLines(proofs, judgmentHref)) note('待っている判断', line.text, 'attention', line.summary, line.link);
2465
+ }
2389
2466
  const rowItems = new Map(rows.map((row) => [row.key, Array.isArray(row.items) ? row.items : []]));
2467
+ const memory = await memoryRead;
2468
+ if (disposed) return;
2469
+ for (const line of memoryLines(memory)) note('記憶', line.text, line.tone, line.summary, line.link);
2390
2470
 
2391
2471
  knownBusinesses = businesses;
2392
2472
  canvasUI?.setCities(businesses, current.code);