@unson/brainbase-mcp 0.6.0 → 0.8.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/contracts/foundation/graph-history.sql +634 -0
- package/contracts/judgment-value-proof/schema.json +43 -1
- package/dist/canonical-task-contract.d.ts +9 -0
- package/dist/canonical-task-contract.js +39 -0
- package/dist/canonical-task-principal.d.ts +16 -0
- package/dist/canonical-task-principal.js +36 -0
- package/dist/canonical-task-service.d.ts +251 -0
- package/dist/canonical-task-service.js +820 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +107 -0
- package/dist/company-os-evaluation.d.ts +193 -0
- package/dist/company-os-evaluation.js +1150 -0
- package/dist/company-os-impact-review.d.ts +248 -0
- package/dist/company-os-impact-review.js +663 -0
- package/dist/company-os-learning-adoption.d.ts +288 -0
- package/dist/company-os-learning-adoption.js +944 -0
- package/dist/company-os-objectives.d.ts +63 -0
- package/dist/company-os-objectives.js +234 -0
- package/dist/company-os-receipt-adapter.d.ts +109 -0
- package/dist/company-os-receipt-adapter.js +636 -0
- package/dist/constraint-exception-store.d.ts +23 -0
- package/dist/constraint-exception-store.js +108 -0
- package/dist/constraint-resolution.d.ts +175 -0
- package/dist/constraint-resolution.js +922 -0
- package/dist/decision-adapter.d.ts +145 -0
- package/dist/decision-adapter.js +586 -0
- package/dist/durable-wait-http.d.ts +51 -0
- package/dist/durable-wait-http.js +497 -0
- package/dist/durable-waits.d.ts +234 -0
- package/dist/durable-waits.js +944 -0
- package/dist/execution-authority-http.d.ts +28 -0
- package/dist/execution-authority-http.js +359 -0
- package/dist/execution-authority.d.ts +201 -0
- package/dist/execution-authority.js +634 -0
- package/dist/foundation-authenticated-tools.d.ts +56 -0
- package/dist/foundation-authenticated-tools.js +347 -0
- package/dist/foundation-catalog.d.ts +26 -0
- package/dist/foundation-catalog.js +206 -0
- package/dist/foundation-constraint-store.d.ts +50 -0
- package/dist/foundation-constraint-store.js +215 -0
- package/dist/foundation-graph-http.d.ts +43 -0
- package/dist/foundation-graph-http.js +243 -0
- package/dist/foundation-graph-write.d.ts +90 -0
- package/dist/foundation-graph-write.js +447 -0
- package/dist/foundation-http.d.ts +90 -0
- package/dist/foundation-http.js +439 -0
- package/dist/foundation-public-provider.d.ts +140 -0
- package/dist/foundation-public-provider.js +192 -0
- package/dist/foundation-store.d.ts +132 -0
- package/dist/foundation-store.js +654 -0
- package/dist/graph-corrections.d.ts +157 -0
- package/dist/graph-corrections.js +620 -0
- package/dist/graph-foundation-reader.d.ts +60 -0
- package/dist/graph-foundation-reader.js +494 -0
- package/dist/graph-retrieval.d.ts +6 -1
- package/dist/graph-revision-reader.d.ts +80 -0
- package/dist/graph-revision-reader.js +246 -0
- package/dist/graph-web-http.d.ts +26 -0
- package/dist/graph-web-http.js +215 -0
- package/dist/graph-web.d.ts +364 -0
- package/dist/graph-web.js +639 -0
- package/dist/judgment-dag-composition.d.ts +224 -0
- package/dist/judgment-dag-composition.js +731 -0
- package/dist/judgment-dag.d.ts +2 -0
- package/dist/judgment-dag.js +1 -0
- package/dist/judgment-problem-snapshot.d.ts +213 -0
- package/dist/judgment-problem-snapshot.js +1073 -0
- package/dist/judgment-value-proof-review.d.ts +166 -0
- package/dist/judgment-value-proof-review.js +437 -0
- package/dist/judgment-value-proof.d.ts +28 -1
- package/dist/judgment-value-proof.js +53 -8
- package/dist/judgment-view-http.d.ts +22 -0
- package/dist/judgment-view-http.js +128 -0
- package/dist/judgment-view.d.ts +222 -0
- package/dist/judgment-view.js +627 -0
- package/dist/knowledge-adapter.d.ts +112 -0
- package/dist/knowledge-adapter.js +691 -0
- package/dist/knowledge-continuation.d.ts +127 -0
- package/dist/knowledge-continuation.js +446 -0
- package/dist/knowledge-lookup.d.ts +178 -0
- package/dist/knowledge-lookup.js +758 -0
- package/dist/local-web-fetch-bridge.d.ts +5 -0
- package/dist/local-web-fetch-bridge.js +37 -0
- package/dist/local-web-host.d.ts +109 -0
- package/dist/local-web-host.js +688 -0
- package/dist/local-web-security.d.ts +25 -0
- package/dist/local-web-security.js +95 -0
- package/dist/ontology-foundation.d.ts +209 -0
- package/dist/ontology-foundation.js +1000 -0
- package/dist/ontology.d.ts +13 -0
- package/dist/ontology.js +14 -0
- package/dist/organization-connection.d.ts +164 -0
- package/dist/organization-connection.js +428 -0
- package/dist/organization-graph.d.ts +15 -0
- package/dist/organization-graph.js +24 -4
- package/dist/personal-knowledge.d.ts +132 -0
- package/dist/personal-knowledge.js +779 -0
- package/dist/personal-memory-handover.d.ts +108 -0
- package/dist/personal-memory-handover.js +369 -0
- package/dist/philosophy-revision-reader.d.ts +85 -0
- package/dist/philosophy-revision-reader.js +241 -0
- package/dist/portable-graph.d.ts +24 -0
- package/dist/portable-graph.js +77 -0
- package/dist/problem-candidates.d.ts +149 -0
- package/dist/problem-candidates.js +898 -0
- package/dist/problem-selection.d.ts +238 -0
- package/dist/problem-selection.js +995 -0
- package/dist/resource-reservation-http.d.ts +31 -0
- package/dist/resource-reservation-http.js +371 -0
- package/dist/resource-reservations.d.ts +221 -0
- package/dist/resource-reservations.js +577 -0
- package/dist/server.d.ts +104 -2
- package/dist/server.js +186 -4
- package/dist/ssot.d.ts +6 -1
- package/dist/ssot.js +63 -5
- package/dist/streamable-http.d.ts +42 -0
- package/dist/streamable-http.js +288 -0
- package/dist/types.d.ts +20 -0
- package/dist/value-proof-review-http.d.ts +25 -0
- package/dist/value-proof-review-http.js +185 -0
- package/dist/world-model.d.ts +199 -0
- package/dist/world-model.js +955 -0
- package/package.json +230 -3
- package/ui/brainbase-tokens.css +56 -0
- package/ui/graph-own-share.js +367 -0
- package/ui/graph-projects-view.css +23 -0
- package/ui/graph-projects-view.js +730 -0
- package/ui/graph-registry-view.css +101 -0
- package/ui/graph-registry-view.js +804 -0
- package/ui/graph-view-shared.css +522 -0
- package/ui/graph-view-shared.js +1227 -0
- package/ui/judgment-view.css +312 -0
- package/ui/judgment-view.js +508 -0
- package/ui/local-web-shell.css +396 -0
- package/ui/local-web-shell.js +378 -0
- package/ui/objective-editor-http-port.js +154 -0
- package/ui/objective-editor.css +286 -0
- package/ui/objective-editor.js +1586 -0
- package/ui/outcome-knowledge.css +50 -14
- package/ui/outcome-knowledge.js +15 -1
- package/ui/outcome-mana.css +91 -14
- package/ui/outcome-mana.js +958 -61
- package/ui/value-proof-review.css +504 -0
- package/ui/value-proof-review.js +1104 -0
- package/ui/workspace-kit.css +485 -0
- package/ui/workspace-kit.js +229 -0
- package/ui/world-model-view.css +188 -0
- package/ui/world-model-view.js +486 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { initializePersonalOs, mutatePersonalOsWithSidecar, readPersonalOsSidecar } from './ssot.js';
|
|
3
|
+
/**
|
|
4
|
+
* Story 15 keeps impact review at the port boundary. It reads adopted
|
|
5
|
+
* references and plans, records an idempotent notification, and asks a
|
|
6
|
+
* durable-wait/rejudgment adapter to continue. It never owns a canonical
|
|
7
|
+
* Objective, Model, Constraint, run, reservation, or external effect.
|
|
8
|
+
*/
|
|
9
|
+
export const COMPANY_OS_IMPACT_REVIEW_VERSION = 'company-os-impact-review.v1';
|
|
10
|
+
export const COMPANY_OS_IMPACT_REVIEW_NOTIFICATION_SIDECAR = 'impact-review/notifications.json';
|
|
11
|
+
/**
|
|
12
|
+
* Adapt Story 13's canonical durable-wait ledger to the impact-review port.
|
|
13
|
+
* The coordinator remains independent from the ledger implementation; this
|
|
14
|
+
* boundary only forwards the wait operations it is allowed to request.
|
|
15
|
+
*/
|
|
16
|
+
export function createCompanyOsImpactReviewDurableWaitPort(store) {
|
|
17
|
+
if (!store || typeof store.create !== 'function' || typeof store.get !== 'function' || typeof store.markPremiseChanged !== 'function') {
|
|
18
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'durable wait store is required');
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
create(input) {
|
|
22
|
+
return store.create(input);
|
|
23
|
+
},
|
|
24
|
+
read(input) {
|
|
25
|
+
return store.get(input);
|
|
26
|
+
},
|
|
27
|
+
markPremiseChanged(input) {
|
|
28
|
+
return store.markPremiseChanged(input);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export class CompanyOsImpactReviewError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'CompanyOsImpactReviewError';
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Transactional, reference-only notification storage. The sidecar never
|
|
42
|
+
* stores an Objective/Model/Constraint body and never changes canonical SSOT.
|
|
43
|
+
*/
|
|
44
|
+
export class AtomicCompanyOsImpactReviewNotificationStore {
|
|
45
|
+
dataDir;
|
|
46
|
+
sidecarPath;
|
|
47
|
+
clock;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
if (!options || typeof options.dataDir !== 'string' || options.dataDir.trim() === '') {
|
|
50
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'impact review notification dataDir is required');
|
|
51
|
+
}
|
|
52
|
+
this.dataDir = options.dataDir;
|
|
53
|
+
this.sidecarPath = options.sidecarPath ?? COMPANY_OS_IMPACT_REVIEW_NOTIFICATION_SIDECAR;
|
|
54
|
+
this.clock = options.clock ?? (() => new Date());
|
|
55
|
+
}
|
|
56
|
+
async record(input) {
|
|
57
|
+
const normalized = normalizeNotificationInput(input, this.clock);
|
|
58
|
+
const notification_id = computeImpactReviewNotificationId(normalized);
|
|
59
|
+
const candidate = {
|
|
60
|
+
version: COMPANY_OS_IMPACT_REVIEW_VERSION,
|
|
61
|
+
notification_id,
|
|
62
|
+
...normalized,
|
|
63
|
+
};
|
|
64
|
+
await initializePersonalOs(this.dataDir);
|
|
65
|
+
return mutatePersonalOsWithSidecar(this.dataDir, this.sidecarPath, async (current, content) => {
|
|
66
|
+
const ledger = parseNotificationLedger(content);
|
|
67
|
+
const existing = ledger.notifications.find((item) => item.notification_id === notification_id);
|
|
68
|
+
if (existing) {
|
|
69
|
+
if (!sameNotificationPayload(existing, candidate)) {
|
|
70
|
+
throw new CompanyOsImpactReviewError('notification_conflict', `impact review notification ${notification_id} has a different payload`);
|
|
71
|
+
}
|
|
72
|
+
return { next: current, sidecarContent: serializeNotificationLedger(ledger), result: { record: clone(existing), status: 'existing' } };
|
|
73
|
+
}
|
|
74
|
+
const notifications = [...ledger.notifications, candidate].sort((left, right) => left.notification_id.localeCompare(right.notification_id, 'en'));
|
|
75
|
+
return {
|
|
76
|
+
next: current,
|
|
77
|
+
sidecarContent: serializeNotificationLedger({ version: COMPANY_OS_IMPACT_REVIEW_VERSION, notifications }),
|
|
78
|
+
result: { record: clone(candidate), status: 'created' },
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async read() {
|
|
83
|
+
await initializePersonalOs(this.dataDir);
|
|
84
|
+
return parseNotificationLedger(await readPersonalOsSidecar(this.dataDir, this.sidecarPath)).notifications.map(clone);
|
|
85
|
+
}
|
|
86
|
+
async list() {
|
|
87
|
+
return this.read();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function createCompanyOsImpactReviewNotificationStore(options) {
|
|
91
|
+
return new AtomicCompanyOsImpactReviewNotificationStore(options);
|
|
92
|
+
}
|
|
93
|
+
export class CompanyOsImpactReviewCoordinator {
|
|
94
|
+
currentReferences;
|
|
95
|
+
index;
|
|
96
|
+
notifications;
|
|
97
|
+
waits;
|
|
98
|
+
rejudgment;
|
|
99
|
+
constructor(options) {
|
|
100
|
+
if (!options || !options.currentReferences || typeof options.currentReferences.readCurrent !== 'function') {
|
|
101
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'current reference port is required');
|
|
102
|
+
}
|
|
103
|
+
if (!options.index || typeof options.index.findAffectedPlans !== 'function') {
|
|
104
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'impact index port is required');
|
|
105
|
+
}
|
|
106
|
+
if (!options.notifications || typeof options.notifications.record !== 'function') {
|
|
107
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'notification store is required');
|
|
108
|
+
}
|
|
109
|
+
this.currentReferences = options.currentReferences;
|
|
110
|
+
this.index = options.index;
|
|
111
|
+
this.notifications = options.notifications;
|
|
112
|
+
this.waits = options.waits;
|
|
113
|
+
this.rejudgment = options.rejudgment;
|
|
114
|
+
}
|
|
115
|
+
async review(request) {
|
|
116
|
+
validateAccess(request.access);
|
|
117
|
+
if (!Array.isArray(request.changes) || request.changes.length === 0) {
|
|
118
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'at least one impact change is required');
|
|
119
|
+
}
|
|
120
|
+
const changes = request.changes.map(validateChange);
|
|
121
|
+
validateUniqueChangeIds(changes);
|
|
122
|
+
const resolved = [];
|
|
123
|
+
const references = [];
|
|
124
|
+
const unresolved_changes = [];
|
|
125
|
+
for (const change of changes) {
|
|
126
|
+
try {
|
|
127
|
+
const current = await this.currentReferences.readCurrent(change.current, request.access);
|
|
128
|
+
if (!current) {
|
|
129
|
+
unresolved_changes.push({ change, reason: 'current reference could not be resolved' });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!sameReference(current, change.current)) {
|
|
133
|
+
unresolved_changes.push({ change, reason: 'current reference digest or revision does not match the requested change' });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
resolved.push(change);
|
|
137
|
+
references.push(clone(current));
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
unresolved_changes.push({
|
|
141
|
+
change,
|
|
142
|
+
reason: error instanceof Error ? `current reference read failed: ${error.message}` : 'current reference read failed',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const affected = resolved.length === 0
|
|
147
|
+
? []
|
|
148
|
+
: await this.index.findAffectedPlans({ changes: resolved, current_references: references, access: request.access });
|
|
149
|
+
const grouped = groupAffectedPlans(affected, resolved);
|
|
150
|
+
const decisions = [];
|
|
151
|
+
for (const entry of grouped) {
|
|
152
|
+
const classification = classifyPlan(entry.plan, entry.matches, new Map(resolved.map((change) => [change.change_id, change])));
|
|
153
|
+
const planReferences = referencesForPlan(entry.plan, references);
|
|
154
|
+
let wait_id;
|
|
155
|
+
if (classification.disposition === 'hold') {
|
|
156
|
+
if (!this.waits)
|
|
157
|
+
throw new CompanyOsImpactReviewError('missing_dependency', 'durable wait port is required for a held plan');
|
|
158
|
+
wait_id = computeImpactReviewWaitId(entry.plan.plan_id, entry.matches.map((match) => match.change_id), classification.reason, planReferences);
|
|
159
|
+
}
|
|
160
|
+
const notificationInput = {
|
|
161
|
+
plan_id: entry.plan.plan_id,
|
|
162
|
+
disposition: classification.disposition,
|
|
163
|
+
change_ids: entry.matches.map((match) => match.change_id),
|
|
164
|
+
reason: classification.reason,
|
|
165
|
+
references: planReferences,
|
|
166
|
+
...(wait_id ? { wait_id } : {}),
|
|
167
|
+
};
|
|
168
|
+
const existing = (await this.notifications.read()).find((record) => sameNotificationContent(record, notificationInput));
|
|
169
|
+
if (classification.disposition === 'hold' && !existing) {
|
|
170
|
+
const waitInput = buildWaitInput(entry.plan, request.access, wait_id, classification.reason);
|
|
171
|
+
const existingWait = this.waits?.read
|
|
172
|
+
? await readWaitOrMissing(this.waits, { wait_id: wait_id, principal: request.access.principal })
|
|
173
|
+
: undefined;
|
|
174
|
+
if (existingWait !== undefined) {
|
|
175
|
+
assertWaitPayload(existingWait, waitInput);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
await this.waits.create(waitInput);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const notification = existing
|
|
182
|
+
? { record: existing, status: 'existing' }
|
|
183
|
+
: await this.notifications.record(notificationInput);
|
|
184
|
+
decisions.push({
|
|
185
|
+
plan: clone(entry.plan),
|
|
186
|
+
matches: entry.matches.map(clone),
|
|
187
|
+
disposition: classification.disposition,
|
|
188
|
+
reason: classification.reason,
|
|
189
|
+
notification: notification.record,
|
|
190
|
+
...(wait_id ? { wait_id } : {}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const outcome = unresolved_changes.length > 0
|
|
194
|
+
? (decisions.length > 0 ? 'affected_and_unresolved' : 'unresolved')
|
|
195
|
+
: (decisions.length > 0 ? 'affected' : 'no_affected_plans');
|
|
196
|
+
return {
|
|
197
|
+
outcome,
|
|
198
|
+
decisions,
|
|
199
|
+
resolved_change_ids: resolved.map((change) => change.change_id),
|
|
200
|
+
unresolved_changes,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async reassess(request) {
|
|
204
|
+
validateAccess(request.access);
|
|
205
|
+
validatePlan(request.plan);
|
|
206
|
+
if (!Array.isArray(request.changes) || request.changes.length === 0)
|
|
207
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'at least one change is required to reassess');
|
|
208
|
+
if (request.reason.trim() === '')
|
|
209
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'reassessment reason is required');
|
|
210
|
+
if (request.plan.execution_state === 'completed') {
|
|
211
|
+
throw new CompanyOsImpactReviewError('rejudgment_not_allowed', 'completed plan history is immutable and does not need rejudgment');
|
|
212
|
+
}
|
|
213
|
+
if (request.plan.execution_state === 'effect_unknown') {
|
|
214
|
+
throw new CompanyOsImpactReviewError('rejudgment_not_allowed', 'reconcile the unknown external effect before rejudgment');
|
|
215
|
+
}
|
|
216
|
+
if (!this.rejudgment)
|
|
217
|
+
throw new CompanyOsImpactReviewError('missing_dependency', 'rejudgment port is required');
|
|
218
|
+
const changes = request.changes.map(validateChange);
|
|
219
|
+
validateUniqueChangeIds(changes);
|
|
220
|
+
for (const change of changes) {
|
|
221
|
+
const current = await this.currentReferences.readCurrent(change.current, request.access);
|
|
222
|
+
if (!current || !sameReference(current, change.current)) {
|
|
223
|
+
throw new CompanyOsImpactReviewError('unresolved_reference', `cannot reassess with unresolved reference ${change.current.id}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (request.wait_id) {
|
|
227
|
+
if (!this.waits || !this.waits.read) {
|
|
228
|
+
throw new CompanyOsImpactReviewError('missing_dependency', 'durable wait read port is required to validate reassessment');
|
|
229
|
+
}
|
|
230
|
+
const wait = await this.waits.read({ wait_id: request.wait_id, principal: request.access.principal });
|
|
231
|
+
assertReassessmentWait(wait, request);
|
|
232
|
+
}
|
|
233
|
+
const operation_id = computeImpactReviewRejudgmentOperationId({
|
|
234
|
+
access: request.access,
|
|
235
|
+
plan: request.plan,
|
|
236
|
+
changes,
|
|
237
|
+
reason: request.reason,
|
|
238
|
+
wait_id: request.wait_id,
|
|
239
|
+
});
|
|
240
|
+
const created = await this.rejudgment.createNewProblemAndRun({
|
|
241
|
+
access: request.access,
|
|
242
|
+
plan: clone(request.plan),
|
|
243
|
+
changes,
|
|
244
|
+
reason: request.reason,
|
|
245
|
+
operation_id,
|
|
246
|
+
});
|
|
247
|
+
validateNewProblemAndRun(created);
|
|
248
|
+
if (request.wait_id) {
|
|
249
|
+
if (!this.waits)
|
|
250
|
+
throw new CompanyOsImpactReviewError('missing_dependency', 'durable wait port is required to link reassessment');
|
|
251
|
+
await this.waits.markPremiseChanged({
|
|
252
|
+
wait_id: request.wait_id,
|
|
253
|
+
principal: request.access.principal,
|
|
254
|
+
new_problem_snapshot: clone(created.problem),
|
|
255
|
+
responsible: request.plan.responsible,
|
|
256
|
+
reason: request.reason,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return { ...clone(created), ...(request.wait_id ? { wait_id: request.wait_id } : {}) };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export function computeImpactReviewNotificationId(input) {
|
|
263
|
+
const normalized = normalizeNotificationPayload(input);
|
|
264
|
+
return `sha256:${createHash('sha256').update(canonicalJson(normalized), 'utf8').digest('hex')}`;
|
|
265
|
+
}
|
|
266
|
+
export function computeImpactReviewWaitId(planId, changeIds, reason, references = []) {
|
|
267
|
+
if (!isNonEmpty(planId) || !isNonEmpty(reason) || changeIds.length === 0) {
|
|
268
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'plan id, change ids, and reason are required for a wait id');
|
|
269
|
+
}
|
|
270
|
+
const canonicalReferences = references.map((reference, index) => validateReference(reference, `wait references[${index}]`)).sort(compareReferences);
|
|
271
|
+
const digest = createHash('sha256').update(canonicalJson({
|
|
272
|
+
plan_id: planId,
|
|
273
|
+
change_ids: [...new Set(changeIds)].sort(),
|
|
274
|
+
reason,
|
|
275
|
+
references: canonicalReferences,
|
|
276
|
+
}), 'utf8').digest('hex');
|
|
277
|
+
return `impact-review-${digest}`;
|
|
278
|
+
}
|
|
279
|
+
export function computeImpactReviewRejudgmentOperationId(input) {
|
|
280
|
+
if (!input || !isNonEmpty(input.plan.plan_id) || !isNonEmpty(input.plan.run_id) || !isNonEmpty(input.reason)) {
|
|
281
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'plan, run, and reassessment reason are required for an operation id');
|
|
282
|
+
}
|
|
283
|
+
const digest = createHash('sha256').update(canonicalJson({
|
|
284
|
+
principal: input.access.principal,
|
|
285
|
+
plan_id: input.plan.plan_id,
|
|
286
|
+
run_id: input.plan.run_id,
|
|
287
|
+
problem_snapshot: input.plan.problem_snapshot,
|
|
288
|
+
wait_id: input.wait_id,
|
|
289
|
+
changes: input.changes.map((change) => ({
|
|
290
|
+
change_id: change.change_id,
|
|
291
|
+
source: change.source,
|
|
292
|
+
previous: change.previous,
|
|
293
|
+
current: change.current,
|
|
294
|
+
significance: change.significance,
|
|
295
|
+
reason: change.reason,
|
|
296
|
+
})).sort((left, right) => left.change_id.localeCompare(right.change_id, 'en')),
|
|
297
|
+
reason: input.reason.trim(),
|
|
298
|
+
}), 'utf8').digest('hex');
|
|
299
|
+
return `impact-review-rejudgment-${digest}`;
|
|
300
|
+
}
|
|
301
|
+
function buildWaitInput(plan, access, wait_id, reason) {
|
|
302
|
+
const effectUnknown = plan.execution_state === 'effect_unknown';
|
|
303
|
+
const authorizationUnknown = plan.authority_state === 'unknown' || plan.constraint_state === 'unknown';
|
|
304
|
+
return {
|
|
305
|
+
wait_id,
|
|
306
|
+
principal: access.principal,
|
|
307
|
+
owner_scope: clone(plan.owner_scope),
|
|
308
|
+
read_policy: clone(plan.read_policy),
|
|
309
|
+
problem_snapshot: clone(plan.problem_snapshot),
|
|
310
|
+
condition: {
|
|
311
|
+
...(plan.due_at ? {} : { event: { event_type: 'impact_review', event_id: wait_id } }),
|
|
312
|
+
expression: `impact-review:${plan.plan_id}:${reason}`,
|
|
313
|
+
},
|
|
314
|
+
...(plan.due_at ? { deadline: { due_at: plan.due_at } } : {}),
|
|
315
|
+
responsible: clone(plan.responsible),
|
|
316
|
+
resume_method: effectUnknown ? 'reconcile_external_effect' : authorizationUnknown ? 'manual_review' : 'new_problem',
|
|
317
|
+
failure_policy: effectUnknown ? 'reconciliation_wait' : authorizationUnknown ? 'handoff' : 'new_problem',
|
|
318
|
+
run_ref: { run_id: plan.run_id },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function classifyPlan(plan, matches, changes) {
|
|
322
|
+
if (plan.execution_state === 'effect_unknown') {
|
|
323
|
+
return { disposition: 'hold', reason: 'external effect is unknown; reconciliation is required before any rejudgment' };
|
|
324
|
+
}
|
|
325
|
+
if (plan.authority_state === 'revoked' || plan.authority_state === 'expired') {
|
|
326
|
+
return { disposition: 'stop', reason: `execution authority is ${plan.authority_state}; no further execution is permitted` };
|
|
327
|
+
}
|
|
328
|
+
if (plan.constraint_state === 'violated' || plan.constraint_state === 'expired') {
|
|
329
|
+
return { disposition: 'stop', reason: `constraint is ${plan.constraint_state}; no further execution is permitted` };
|
|
330
|
+
}
|
|
331
|
+
if (plan.execution_state === 'completed') {
|
|
332
|
+
return { disposition: 'continue', reason: 'completed history remains immutable; no pending execution is changed' };
|
|
333
|
+
}
|
|
334
|
+
if (plan.authority_state === 'unknown' || plan.constraint_state === 'unknown') {
|
|
335
|
+
return { disposition: 'hold', reason: 'current authority or constraint status is unknown; responsible review is required' };
|
|
336
|
+
}
|
|
337
|
+
const severities = matches.map((match) => changes.get(match.change_id)?.significance);
|
|
338
|
+
if (severities.includes('premise_refuted'))
|
|
339
|
+
return { disposition: 'hold', reason: 'an adopted premise was refuted; create a new Problem/run after review' };
|
|
340
|
+
if (severities.includes('material'))
|
|
341
|
+
return { disposition: 'hold', reason: 'a material premise changed; create a new Problem/run after review' };
|
|
342
|
+
if (severities.includes('unknown') || severities.some((value) => value === undefined)) {
|
|
343
|
+
return { disposition: 'hold', reason: 'impact significance is unknown; responsible review is required' };
|
|
344
|
+
}
|
|
345
|
+
return { disposition: 'continue', reason: 'only minor changes were found and current authority and constraints remain valid' };
|
|
346
|
+
}
|
|
347
|
+
function groupAffectedPlans(affected, changes) {
|
|
348
|
+
if (!Array.isArray(affected))
|
|
349
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', 'impact index must return an array');
|
|
350
|
+
const changeIds = new Set(changes.map((change) => change.change_id));
|
|
351
|
+
const grouped = new Map();
|
|
352
|
+
for (const item of affected) {
|
|
353
|
+
if (!item || typeof item !== 'object')
|
|
354
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', 'impact index returned an invalid plan');
|
|
355
|
+
const plan = validatePlan(item.plan);
|
|
356
|
+
if (!Array.isArray(item.matches) || item.matches.length === 0)
|
|
357
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${plan.plan_id} has no impact matches`);
|
|
358
|
+
const matches = item.matches.map((match) => validateMatch(match, changeIds));
|
|
359
|
+
const existing = grouped.get(plan.plan_id);
|
|
360
|
+
if (existing) {
|
|
361
|
+
if (canonicalJson(existing.plan) !== canonicalJson(plan))
|
|
362
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${plan.plan_id} was returned with conflicting definitions`);
|
|
363
|
+
existing.matches.push(...matches);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
grouped.set(plan.plan_id, { plan: clone(plan), matches: [...matches] });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return [...grouped.values()].map((entry) => ({
|
|
370
|
+
plan: entry.plan,
|
|
371
|
+
matches: deduplicateMatches(entry.matches),
|
|
372
|
+
})).sort((left, right) => left.plan.plan_id.localeCompare(right.plan.plan_id, 'en'));
|
|
373
|
+
}
|
|
374
|
+
function referencesForPlan(plan, current) {
|
|
375
|
+
const refs = plan.references && plan.references.length > 0 ? plan.references : current;
|
|
376
|
+
return [...refs].map(clone).sort(compareReferences);
|
|
377
|
+
}
|
|
378
|
+
async function readWaitOrMissing(waits, input) {
|
|
379
|
+
if (!waits.read)
|
|
380
|
+
return undefined;
|
|
381
|
+
try {
|
|
382
|
+
return await waits.read(input);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
if (isWaitNotFoundError(error))
|
|
386
|
+
return undefined;
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function isWaitNotFoundError(error) {
|
|
391
|
+
return Boolean(error && typeof error === 'object' && error.code === 'not_found');
|
|
392
|
+
}
|
|
393
|
+
function assertWaitPayload(value, expected) {
|
|
394
|
+
if (!value || typeof value !== 'object')
|
|
395
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', 'durable wait read returned an invalid record');
|
|
396
|
+
const actual = value;
|
|
397
|
+
const comparableActual = {
|
|
398
|
+
wait_id: actual.wait_id,
|
|
399
|
+
owner_scope: actual.owner_scope,
|
|
400
|
+
read_policy: actual.read_policy,
|
|
401
|
+
problem_snapshot: actual.problem_snapshot,
|
|
402
|
+
condition: actual.condition,
|
|
403
|
+
deadline: actual.deadline,
|
|
404
|
+
responsible: actual.responsible,
|
|
405
|
+
resume_method: actual.resume_method,
|
|
406
|
+
failure_policy: actual.failure_policy,
|
|
407
|
+
run_ref: actual.run_ref,
|
|
408
|
+
};
|
|
409
|
+
const comparableExpected = {
|
|
410
|
+
wait_id: expected.wait_id,
|
|
411
|
+
owner_scope: expected.owner_scope,
|
|
412
|
+
read_policy: expected.read_policy,
|
|
413
|
+
problem_snapshot: expected.problem_snapshot,
|
|
414
|
+
condition: expected.condition,
|
|
415
|
+
deadline: expected.deadline,
|
|
416
|
+
responsible: expected.responsible,
|
|
417
|
+
resume_method: expected.resume_method,
|
|
418
|
+
failure_policy: expected.failure_policy,
|
|
419
|
+
run_ref: expected.run_ref,
|
|
420
|
+
};
|
|
421
|
+
if (canonicalJson(comparableActual) !== canonicalJson(comparableExpected)) {
|
|
422
|
+
throw new CompanyOsImpactReviewError('wait_conflict', `durable wait ${expected.wait_id} has a different payload`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function assertReassessmentWait(value, request) {
|
|
426
|
+
if (!value || typeof value !== 'object')
|
|
427
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', 'durable wait read returned an invalid record');
|
|
428
|
+
const record = value;
|
|
429
|
+
if (record.wait_id !== request.wait_id) {
|
|
430
|
+
throw new CompanyOsImpactReviewError('wait_conflict', 'durable wait id does not match the reassessment request');
|
|
431
|
+
}
|
|
432
|
+
if (canonicalJson(record.problem_snapshot) !== canonicalJson(request.plan.problem_snapshot)) {
|
|
433
|
+
throw new CompanyOsImpactReviewError('wait_conflict', 'durable wait Problem snapshot does not match the plan');
|
|
434
|
+
}
|
|
435
|
+
if (record.run_ref?.run_id !== request.plan.run_id) {
|
|
436
|
+
throw new CompanyOsImpactReviewError('wait_conflict', 'durable wait run does not match the plan');
|
|
437
|
+
}
|
|
438
|
+
const expression = record.condition?.expression;
|
|
439
|
+
if (!isNonEmpty(expression) || !expression.startsWith(`impact-review:${request.plan.plan_id}:`)) {
|
|
440
|
+
throw new CompanyOsImpactReviewError('wait_conflict', 'durable wait plan does not match the reassessment request');
|
|
441
|
+
}
|
|
442
|
+
if (record.state === 'reconciliation_wait') {
|
|
443
|
+
throw new CompanyOsImpactReviewError('rejudgment_not_allowed', 'reconcile the durable wait external effect before rejudgment');
|
|
444
|
+
}
|
|
445
|
+
if (record.state === 'resumed' || record.state === 'failed' || record.state === 'cancelled') {
|
|
446
|
+
throw new CompanyOsImpactReviewError('rejudgment_not_allowed', `durable wait is ${String(record.state)} and cannot be reassessed`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function validateAccess(access) {
|
|
450
|
+
if (!access || !isNonEmpty(access.principal))
|
|
451
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'access principal is required');
|
|
452
|
+
if (access.scope)
|
|
453
|
+
validateScope(access.scope, 'access.scope');
|
|
454
|
+
}
|
|
455
|
+
function validateChange(input) {
|
|
456
|
+
if (!input || typeof input !== 'object')
|
|
457
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'impact change is required');
|
|
458
|
+
if (!isNonEmpty(input.change_id) || !isNonEmpty(input.reason))
|
|
459
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'change id and reason are required');
|
|
460
|
+
if (!['world_model', 'objective', 'constraint', 'judgment_method'].includes(input.source))
|
|
461
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'invalid change source');
|
|
462
|
+
if (!['minor', 'material', 'premise_refuted', 'unknown'].includes(input.significance))
|
|
463
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'invalid change significance');
|
|
464
|
+
const current = validateReference(input.current, 'change.current');
|
|
465
|
+
if (input.previous) {
|
|
466
|
+
const previous = validateReference(input.previous, 'change.previous');
|
|
467
|
+
if (previous.kind !== current.kind || previous.id !== current.id)
|
|
468
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'previous and current references must identify the same canonical item');
|
|
469
|
+
}
|
|
470
|
+
return clone({ ...input, current });
|
|
471
|
+
}
|
|
472
|
+
function validateUniqueChangeIds(changes) {
|
|
473
|
+
const seen = new Set();
|
|
474
|
+
for (const change of changes) {
|
|
475
|
+
if (seen.has(change.change_id))
|
|
476
|
+
throw new CompanyOsImpactReviewError('invalid_request', `change id ${change.change_id} appears more than once`);
|
|
477
|
+
seen.add(change.change_id);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function validateReference(input, label) {
|
|
481
|
+
if (!input || typeof input !== 'object' || !isNonEmpty(input.id) || !isNonEmpty(input.revision) || !isNonEmpty(input.digest)) {
|
|
482
|
+
throw new CompanyOsImpactReviewError('invalid_request', `${label} must contain id, revision, and digest`);
|
|
483
|
+
}
|
|
484
|
+
if (!['objective', 'model', 'constraint', 'method', 'dag'].includes(input.kind))
|
|
485
|
+
throw new CompanyOsImpactReviewError('invalid_request', `${label}.kind is invalid`);
|
|
486
|
+
if (!/^sha256:[0-9a-f]{64}$/u.test(input.digest))
|
|
487
|
+
throw new CompanyOsImpactReviewError('invalid_request', `${label}.digest must be sha256:<64 lowercase hex>`);
|
|
488
|
+
validateScope(input.scope, `${label}.scope`);
|
|
489
|
+
return clone(input);
|
|
490
|
+
}
|
|
491
|
+
function validateScope(scope, label) {
|
|
492
|
+
if (!scope || !['personal', 'project', 'organization'].includes(scope.type) || !isNonEmpty(scope.id))
|
|
493
|
+
throw new CompanyOsImpactReviewError('invalid_request', `${label} is invalid`);
|
|
494
|
+
return clone(scope);
|
|
495
|
+
}
|
|
496
|
+
function validatePlan(input) {
|
|
497
|
+
if (!input || typeof input !== 'object' || !isNonEmpty(input.plan_id) || !isNonEmpty(input.run_id))
|
|
498
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', 'plan id and run id are required');
|
|
499
|
+
validateScope(input.owner_scope, `plan ${input.plan_id}.owner_scope`);
|
|
500
|
+
if (!input.read_policy || !isNonEmpty(input.read_policy.ownerId) || !['private', 'project', 'organization', 'public'].includes(input.read_policy.visibility))
|
|
501
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.read_policy is invalid`);
|
|
502
|
+
if (!Array.isArray(input.read_policy.readerIds) || !Array.isArray(input.read_policy.writerIds))
|
|
503
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.read_policy lists are invalid`);
|
|
504
|
+
if (!input.problem_snapshot || !isNonEmpty(input.problem_snapshot.snapshot_id) || !isNonEmpty(input.problem_snapshot.problem_id) || !isNonEmpty(input.problem_snapshot.revision))
|
|
505
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.problem_snapshot is invalid`);
|
|
506
|
+
if (!/^sha256:[0-9a-f]{64}$/u.test(input.problem_snapshot.snapshot_id))
|
|
507
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.problem_snapshot.snapshot_id is invalid`);
|
|
508
|
+
if (!['planned', 'running', 'completed', 'effect_unknown'].includes(input.execution_state))
|
|
509
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.execution_state is invalid`);
|
|
510
|
+
if (!['valid', 'expired', 'revoked', 'unknown'].includes(input.authority_state))
|
|
511
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.authority_state is invalid`);
|
|
512
|
+
if (!['valid', 'violated', 'expired', 'unknown'].includes(input.constraint_state))
|
|
513
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.constraint_state is invalid`);
|
|
514
|
+
if (!input.responsible || !isNonEmpty(input.responsible.principal))
|
|
515
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.responsible is invalid`);
|
|
516
|
+
if (input.due_at !== undefined && !isNonEmpty(input.due_at))
|
|
517
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.due_at is invalid`);
|
|
518
|
+
if (input.references !== undefined && !Array.isArray(input.references))
|
|
519
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `plan ${input.plan_id}.references must be an array`);
|
|
520
|
+
if (input.references)
|
|
521
|
+
input.references.forEach((reference, index) => validateReference(reference, `plan ${input.plan_id}.references[${index}]`));
|
|
522
|
+
return clone(input);
|
|
523
|
+
}
|
|
524
|
+
function validateMatch(input, changeIds) {
|
|
525
|
+
if (!input || !changeIds.has(input.change_id) || !isNonEmpty(input.evidence))
|
|
526
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', 'impact match is invalid or refers to an unresolved change');
|
|
527
|
+
const worldModelRelations = ['uses_as_input', 'predicts', 'adopted', 'applies_to'];
|
|
528
|
+
const executionRelations = ['execution_depends_on', 'selected_method'];
|
|
529
|
+
if (!['world_model', 'execution'].includes(input.domain))
|
|
530
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `relation domain ${input.domain} is invalid`);
|
|
531
|
+
if ((input.domain === 'world_model' && !worldModelRelations.includes(input.relation)) || (input.domain === 'execution' && !executionRelations.includes(input.relation))) {
|
|
532
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', `relation ${input.relation} is invalid for domain ${input.domain}`);
|
|
533
|
+
}
|
|
534
|
+
return clone(input);
|
|
535
|
+
}
|
|
536
|
+
function validateNewProblemAndRun(value) {
|
|
537
|
+
if (!value || !value.problem || !isNonEmpty(value.problem.snapshot_id) || !isNonEmpty(value.problem.problem_id) || !isNonEmpty(value.problem.revision) || !/^sha256:[0-9a-f]{64}$/u.test(value.problem.snapshot_id) || !isNonEmpty(value.run_id)) {
|
|
538
|
+
throw new CompanyOsImpactReviewError('invalid_provider_result', 'rejudgment port returned an invalid Problem/run');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function normalizeNotificationInput(input, clock) {
|
|
542
|
+
if (!input || !isNonEmpty(input.plan_id) || !isNonEmpty(input.reason) || !Array.isArray(input.change_ids) || input.change_ids.length === 0 || !Array.isArray(input.references)) {
|
|
543
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'notification plan, reason, change ids, and references are required');
|
|
544
|
+
}
|
|
545
|
+
if (!['continue', 'hold', 'stop'].includes(input.disposition))
|
|
546
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'notification disposition is invalid');
|
|
547
|
+
const change_ids = [...new Set(input.change_ids.map((id) => String(id).trim()))].sort((left, right) => left.localeCompare(right, 'en'));
|
|
548
|
+
if (change_ids.some((id) => id === ''))
|
|
549
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'notification change ids must be non-empty');
|
|
550
|
+
const references = input.references.map((reference, index) => validateReference(reference, `notification.references[${index}]`)).sort(compareReferences);
|
|
551
|
+
const created_at = input.created_at ?? clock().toISOString();
|
|
552
|
+
if (!isNonEmpty(created_at) || Number.isNaN(Date.parse(created_at)))
|
|
553
|
+
throw new CompanyOsImpactReviewError('invalid_request', 'notification created_at must be a valid timestamp');
|
|
554
|
+
return {
|
|
555
|
+
plan_id: input.plan_id.trim(),
|
|
556
|
+
disposition: input.disposition,
|
|
557
|
+
change_ids,
|
|
558
|
+
reason: input.reason.trim(),
|
|
559
|
+
references,
|
|
560
|
+
...(input.wait_id ? { wait_id: input.wait_id } : {}),
|
|
561
|
+
created_at,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function normalizeNotificationPayload(input) {
|
|
565
|
+
return {
|
|
566
|
+
plan_id: input.plan_id.trim(),
|
|
567
|
+
disposition: input.disposition,
|
|
568
|
+
change_ids: [...new Set(input.change_ids.map((id) => String(id).trim()))].sort((left, right) => left.localeCompare(right, 'en')),
|
|
569
|
+
reason: input.reason.trim(),
|
|
570
|
+
references: [...input.references].map(clone).sort(compareReferences),
|
|
571
|
+
...(input.wait_id ? { wait_id: input.wait_id } : {}),
|
|
572
|
+
...(input.created_at !== undefined ? { created_at: input.created_at } : {}),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function parseNotificationLedger(content) {
|
|
576
|
+
if (content === undefined || content.trim() === '')
|
|
577
|
+
return { version: COMPANY_OS_IMPACT_REVIEW_VERSION, notifications: [] };
|
|
578
|
+
let value;
|
|
579
|
+
try {
|
|
580
|
+
value = JSON.parse(content);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
throw new CompanyOsImpactReviewError('corrupt_sidecar', 'impact review notification sidecar is not valid JSON');
|
|
584
|
+
}
|
|
585
|
+
if (!value || typeof value !== 'object' || value.version !== COMPANY_OS_IMPACT_REVIEW_VERSION || !Array.isArray(value.notifications)) {
|
|
586
|
+
throw new CompanyOsImpactReviewError('corrupt_sidecar', 'impact review notification sidecar has an invalid envelope');
|
|
587
|
+
}
|
|
588
|
+
const notifications = value.notifications.map((item, index) => parseNotificationRecord(item, index));
|
|
589
|
+
const ids = new Set();
|
|
590
|
+
for (const record of notifications) {
|
|
591
|
+
if (ids.has(record.notification_id))
|
|
592
|
+
throw new CompanyOsImpactReviewError('corrupt_sidecar', `duplicate notification id ${record.notification_id}`);
|
|
593
|
+
ids.add(record.notification_id);
|
|
594
|
+
}
|
|
595
|
+
return { version: COMPANY_OS_IMPACT_REVIEW_VERSION, notifications: notifications.sort((left, right) => left.notification_id.localeCompare(right.notification_id, 'en')) };
|
|
596
|
+
}
|
|
597
|
+
function parseNotificationRecord(value, index) {
|
|
598
|
+
if (!value || typeof value !== 'object')
|
|
599
|
+
throw new CompanyOsImpactReviewError('corrupt_sidecar', `notification ${index + 1} is invalid`);
|
|
600
|
+
const item = value;
|
|
601
|
+
if (item.version !== COMPANY_OS_IMPACT_REVIEW_VERSION || !isNonEmpty(item.created_at) || Number.isNaN(Date.parse(item.created_at))) {
|
|
602
|
+
throw new CompanyOsImpactReviewError('corrupt_sidecar', `notification ${index + 1} has invalid version or created_at`);
|
|
603
|
+
}
|
|
604
|
+
let normalized;
|
|
605
|
+
try {
|
|
606
|
+
normalized = normalizeNotificationInput(item, () => new Date(item.created_at));
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
if (error instanceof CompanyOsImpactReviewError && error.code === 'corrupt_sidecar')
|
|
610
|
+
throw error;
|
|
611
|
+
throw new CompanyOsImpactReviewError('corrupt_sidecar', `notification ${index + 1} has an invalid payload`);
|
|
612
|
+
}
|
|
613
|
+
if (!isNonEmpty(item.notification_id) || item.notification_id !== computeImpactReviewNotificationId(normalized))
|
|
614
|
+
throw new CompanyOsImpactReviewError('corrupt_sidecar', `notification ${index + 1} has an invalid id`);
|
|
615
|
+
return { version: COMPANY_OS_IMPACT_REVIEW_VERSION, notification_id: item.notification_id, ...normalized };
|
|
616
|
+
}
|
|
617
|
+
function serializeNotificationLedger(ledger) {
|
|
618
|
+
return `${JSON.stringify({ version: ledger.version, notifications: ledger.notifications })}\n`;
|
|
619
|
+
}
|
|
620
|
+
function sameNotificationPayload(left, right) {
|
|
621
|
+
return canonicalJson({ ...left, notification_id: undefined }) === canonicalJson({ ...right, notification_id: undefined });
|
|
622
|
+
}
|
|
623
|
+
function sameNotificationContent(left, right) {
|
|
624
|
+
return canonicalJson(notificationContentPayload(left)) === canonicalJson(notificationContentPayload(right));
|
|
625
|
+
}
|
|
626
|
+
function notificationContentPayload(input) {
|
|
627
|
+
return {
|
|
628
|
+
plan_id: input.plan_id.trim(),
|
|
629
|
+
disposition: input.disposition,
|
|
630
|
+
change_ids: [...new Set(input.change_ids.map((id) => String(id).trim()))].sort((left, right) => left.localeCompare(right, 'en')),
|
|
631
|
+
reason: input.reason.trim(),
|
|
632
|
+
references: [...input.references].map(clone).sort(compareReferences),
|
|
633
|
+
...(input.wait_id ? { wait_id: input.wait_id } : {}),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function deduplicateMatches(matches) {
|
|
637
|
+
const map = new Map();
|
|
638
|
+
for (const match of matches)
|
|
639
|
+
map.set(`${match.change_id}\u0000${match.domain}\u0000${match.relation}\u0000${match.evidence}`, match);
|
|
640
|
+
return [...map.values()].sort((left, right) => `${left.change_id}\u0000${left.domain}\u0000${left.relation}\u0000${left.evidence}`.localeCompare(`${right.change_id}\u0000${right.domain}\u0000${right.relation}\u0000${right.evidence}`, 'en'));
|
|
641
|
+
}
|
|
642
|
+
function sameReference(left, right) {
|
|
643
|
+
return left.kind === right.kind && left.id === right.id && left.revision === right.revision && left.digest === right.digest && canonicalJson(left.scope) === canonicalJson(right.scope);
|
|
644
|
+
}
|
|
645
|
+
function compareReferences(left, right) {
|
|
646
|
+
return `${left.kind}\u0000${left.id}\u0000${left.revision}\u0000${left.digest}\u0000${left.scope.type}\u0000${left.scope.id}`.localeCompare(`${right.kind}\u0000${right.id}\u0000${right.revision}\u0000${right.digest}\u0000${right.scope.type}\u0000${right.scope.id}`, 'en');
|
|
647
|
+
}
|
|
648
|
+
function isNonEmpty(value) {
|
|
649
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
650
|
+
}
|
|
651
|
+
function clone(value) {
|
|
652
|
+
return JSON.parse(JSON.stringify(value));
|
|
653
|
+
}
|
|
654
|
+
function canonicalJson(value) {
|
|
655
|
+
return JSON.stringify(canonicalize(value));
|
|
656
|
+
}
|
|
657
|
+
function canonicalize(value) {
|
|
658
|
+
if (Array.isArray(value))
|
|
659
|
+
return value.map(canonicalize);
|
|
660
|
+
if (!value || typeof value !== 'object')
|
|
661
|
+
return value;
|
|
662
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right, 'en')).map(([key, item]) => [key, canonicalize(item)]));
|
|
663
|
+
}
|