@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.
- package/dist/graph-maintenance-engine.d.ts +44 -0
- package/dist/graph-maintenance-engine.js +804 -0
- package/dist/memory-scope-policy.d.ts +43 -0
- package/dist/memory-scope-policy.js +159 -0
- package/package.json +10 -2
- package/ui/world/world-canvas-ui.css +8 -0
- package/ui/world/world-canvas-ui.js +3 -1
- package/ui/world/world-view.css +1 -0
- package/ui/world/world-view.js +84 -4
|
@@ -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.
|
|
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": "
|
|
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() {
|
package/ui/world/world-view.css
CHANGED
|
@@ -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%);
|
package/ui/world/world-view.js
CHANGED
|
@@ -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);
|