@unson/brainbase-mcp 0.5.0 → 0.7.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/README.md +8 -0
- package/contracts/judgment-dag/digest.json +2 -2
- 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 +809 -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 +52 -0
- package/dist/company-os-objectives.js +197 -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-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-http.d.ts +90 -0
- package/dist/foundation-http.js +439 -0
- package/dist/foundation-store.d.ts +132 -0
- package/dist/foundation-store.js +654 -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 +185 -0
- package/dist/judgment-problem-snapshot.js +777 -0
- 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/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/personal-knowledge.d.ts +120 -0
- package/dist/personal-knowledge.js +750 -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 +97 -0
- package/dist/server.js +180 -1
- 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/world-model.d.ts +181 -0
- package/dist/world-model.js +873 -0
- package/package.json +145 -3
- package/ui/icons/mana/LICENSE.tabler.txt +21 -0
- package/ui/icons/mana/affiliate.svg +20 -0
- package/ui/icons/mana/brand-github.svg +15 -0
- package/ui/icons/mana/brand-google-drive.svg +17 -0
- package/ui/icons/mana/brand-slack.svg +18 -0
- package/ui/icons/mana/cpu.svg +24 -0
- package/ui/judgment-view.css +238 -0
- package/ui/judgment-view.js +508 -0
- package/ui/objective-editor.css +180 -0
- package/ui/objective-editor.js +1139 -0
- package/ui/outcome-knowledge.css +365 -0
- package/ui/outcome-knowledge.js +1570 -0
- package/ui/outcome-mana.css +255 -0
- package/ui/outcome-mana.js +2325 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { findFoundationRecord, findLatestFoundationRecord, foundationCatalogOrEmpty } from './foundation-catalog.js';
|
|
3
|
+
import { loadPersonalOs, mutatePersonalOsWithSidecar, readPersonalOsSidecar } from './ssot.js';
|
|
4
|
+
/**
|
|
5
|
+
* Problem candidates are evidence-sidecar records. They are deliberately not
|
|
6
|
+
* Graph entities: a candidate is a reviewable input to problem selection, not
|
|
7
|
+
* an approved problem or a canonical fact.
|
|
8
|
+
*/
|
|
9
|
+
export const PROBLEM_CANDIDATES_CONTRACT_VERSION = 'problem-candidates.v1';
|
|
10
|
+
export const PROBLEM_CANDIDATE_CATALOG_VERSION = 1;
|
|
11
|
+
export const PROBLEM_CANDIDATES_SIDECAR = 'evidence/problem-candidates.json';
|
|
12
|
+
export class ProblemCandidateStoreError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'ProblemCandidateStoreError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Validates, clones, and freezes a candidate input. The function is also a
|
|
22
|
+
* pure contract surface for providers that want to validate before storage.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeProblemCandidate(input) {
|
|
25
|
+
if (!isRecord(input))
|
|
26
|
+
throw invalid('candidate must be an object');
|
|
27
|
+
assertOnlyKeys(input, [
|
|
28
|
+
'acl', 'createdAt', 'deadline', 'event', 'expectedEffect', 'id', 'kind',
|
|
29
|
+
'mergedFrom', 'observedGap', 'objectiveRefs', 'opportunity', 'ownerScope',
|
|
30
|
+
'requiredResources', 'responsibleId', 'statement', 'status', 'threat', 'uncertainty'
|
|
31
|
+
], 'candidate');
|
|
32
|
+
const id = requireIdentifier(input.id, 'id');
|
|
33
|
+
const kind = requireCandidateKind(input.kind, 'kind');
|
|
34
|
+
const statement = requireText(input.statement, 'statement');
|
|
35
|
+
if (!Array.isArray(input.objectiveRefs) || input.objectiveRefs.length === 0) {
|
|
36
|
+
throw invalid('objectiveRefs must contain at least one Objective revision');
|
|
37
|
+
}
|
|
38
|
+
const objectiveRefs = input.objectiveRefs.map((reference, index) => normalizeObjectiveReference(reference, `objectiveRefs[${index}]`));
|
|
39
|
+
assertUnique(objectiveRefs.map((reference) => `${reference.id}@${reference.revision}`), 'objectiveRefs');
|
|
40
|
+
const event = normalizeEvent(input.event);
|
|
41
|
+
const ownerScope = normalizeScope(input.ownerScope, 'ownerScope', true);
|
|
42
|
+
const acl = normalizeAcl(input.acl, 'acl');
|
|
43
|
+
const normalized = {
|
|
44
|
+
id,
|
|
45
|
+
kind,
|
|
46
|
+
statement,
|
|
47
|
+
objectiveRefs,
|
|
48
|
+
event,
|
|
49
|
+
observedGap: normalizeTextField(input.observedGap, 'observedGap'),
|
|
50
|
+
opportunity: normalizeTextField(input.opportunity, 'opportunity'),
|
|
51
|
+
threat: normalizeTextField(input.threat, 'threat'),
|
|
52
|
+
uncertainty: normalizeTextField(input.uncertainty, 'uncertainty'),
|
|
53
|
+
deadline: normalizeDeadlineField(input.deadline, 'deadline'),
|
|
54
|
+
expectedEffect: normalizeTextField(input.expectedEffect, 'expectedEffect'),
|
|
55
|
+
requiredResources: normalizeResourceField(input.requiredResources, 'requiredResources'),
|
|
56
|
+
responsibleId: normalizeTextField(input.responsibleId, 'responsibleId'),
|
|
57
|
+
ownerScope,
|
|
58
|
+
acl,
|
|
59
|
+
...(input.mergedFrom === undefined ? {} : { mergedFrom: normalizeIdentifiers(input.mergedFrom, 'mergedFrom') }),
|
|
60
|
+
...(input.createdAt === undefined ? {} : { createdAt: requireIsoTimestamp(input.createdAt, 'createdAt') }),
|
|
61
|
+
...(input.status === undefined ? {} : { status: normalizeCandidateStatus(input.status, 'status') })
|
|
62
|
+
};
|
|
63
|
+
if (normalized.createdAt !== undefined && Date.parse(normalized.createdAt) < Date.parse(event.recordedAt)) {
|
|
64
|
+
throw invalid('createdAt cannot precede event.recordedAt');
|
|
65
|
+
}
|
|
66
|
+
if (normalized.status !== undefined && normalized.status !== 'candidate') {
|
|
67
|
+
throw invalid('candidate input cannot select or approve a candidate');
|
|
68
|
+
}
|
|
69
|
+
return deepFreeze(normalized);
|
|
70
|
+
}
|
|
71
|
+
/** Returns validation without throwing, useful for contract fixtures. */
|
|
72
|
+
export function validateProblemCandidate(input) {
|
|
73
|
+
try {
|
|
74
|
+
normalizeProblemCandidate(input);
|
|
75
|
+
return { valid: true, issues: [] };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return { valid: false, issues: [formatError(error)] };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function createProblemCandidateStore(options) {
|
|
82
|
+
if (!options || !isNonEmptyString(options.dataDir)) {
|
|
83
|
+
throw new ProblemCandidateStoreError('invalid_input', 'dataDir is required');
|
|
84
|
+
}
|
|
85
|
+
if (!options.foundationStore || typeof options.foundationStore.read !== 'function') {
|
|
86
|
+
throw new ProblemCandidateStoreError('invalid_input', 'foundationStore with read is required');
|
|
87
|
+
}
|
|
88
|
+
if (!options.evidenceAccessProvider || typeof options.evidenceAccessProvider.authorize !== 'function') {
|
|
89
|
+
throw new ProblemCandidateStoreError('invalid_input', 'evidenceAccessProvider.authorize is required');
|
|
90
|
+
}
|
|
91
|
+
return new GraphProblemCandidateStore(options.dataDir, options.foundationStore, options.evidenceAccessProvider);
|
|
92
|
+
}
|
|
93
|
+
class GraphProblemCandidateStore {
|
|
94
|
+
dataDir;
|
|
95
|
+
foundationStore;
|
|
96
|
+
evidenceAccessProvider;
|
|
97
|
+
constructor(dataDir, foundationStore, evidenceAccessProvider) {
|
|
98
|
+
this.dataDir = dataDir;
|
|
99
|
+
this.foundationStore = foundationStore;
|
|
100
|
+
this.evidenceAccessProvider = evidenceAccessProvider;
|
|
101
|
+
}
|
|
102
|
+
async saveCandidate(input, context) {
|
|
103
|
+
assertContext(context);
|
|
104
|
+
const candidate = normalizeProblemCandidate(input);
|
|
105
|
+
if (candidate.mergedFrom !== undefined && candidate.mergedFrom.length > 0) {
|
|
106
|
+
throw new ProblemCandidateStoreError('invalid_input', 'Use mergeCandidates to retain merge provenance');
|
|
107
|
+
}
|
|
108
|
+
const objectiveRecords = await this.readObjectives(candidate.objectiveRefs, context);
|
|
109
|
+
assertCandidateWrite(candidate, context);
|
|
110
|
+
let committed;
|
|
111
|
+
await this.mutateCatalog(async (catalog, current) => {
|
|
112
|
+
assertObjectivesAtAggregate(current, candidate.objectiveRefs, objectiveRecords, context);
|
|
113
|
+
await this.authorizeEvidence(candidate.event.sourceRefs, context, 'save');
|
|
114
|
+
const payloadDigest = digestCandidateInput(candidate);
|
|
115
|
+
const existingEvent = catalog.events.find((event) => event.eventId === candidate.event.id);
|
|
116
|
+
if (existingEvent !== undefined) {
|
|
117
|
+
if (existingEvent.payloadDigest !== payloadDigest) {
|
|
118
|
+
throw new ProblemCandidateStoreError('event_conflict', `Event ${candidate.event.id} was already ingested with a different payload`);
|
|
119
|
+
}
|
|
120
|
+
const existing = catalog.candidates.find((item) => item.id === existingEvent.candidateId);
|
|
121
|
+
if (!existing)
|
|
122
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Event ${candidate.event.id} points to a missing candidate`);
|
|
123
|
+
assertCandidateRead(existing, context);
|
|
124
|
+
await this.authorizeCandidateEvidence(catalog, existing, context, 'read');
|
|
125
|
+
committed = cloneCandidate(existing);
|
|
126
|
+
return catalog;
|
|
127
|
+
}
|
|
128
|
+
if (catalog.candidates.some((item) => item.id === candidate.id)) {
|
|
129
|
+
throw new ProblemCandidateStoreError('revision_conflict', `Candidate ${candidate.id} already exists`);
|
|
130
|
+
}
|
|
131
|
+
const record = makeCandidateRecord(candidate, payloadDigest, []);
|
|
132
|
+
committed = record;
|
|
133
|
+
return {
|
|
134
|
+
version: PROBLEM_CANDIDATE_CATALOG_VERSION,
|
|
135
|
+
candidates: [...catalog.candidates, record],
|
|
136
|
+
events: [...catalog.events, { eventId: candidate.event.id, payloadDigest, candidateId: candidate.id }]
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
if (!committed)
|
|
140
|
+
throw new ProblemCandidateStoreError('readback_mismatch', 'Candidate commit did not produce a record');
|
|
141
|
+
const readback = await this.readCandidate(committed.id, context);
|
|
142
|
+
if (!readback)
|
|
143
|
+
throw new ProblemCandidateStoreError('readback_mismatch', `Candidate ${committed.id} was not readable after commit`);
|
|
144
|
+
return readback;
|
|
145
|
+
}
|
|
146
|
+
async saveProblemCandidate(input, context) {
|
|
147
|
+
return this.saveCandidate(input, context);
|
|
148
|
+
}
|
|
149
|
+
async mergeCandidates(input, context) {
|
|
150
|
+
assertContext(context);
|
|
151
|
+
if (!Array.isArray(input.sourceCandidateIds) || input.sourceCandidateIds.length === 0) {
|
|
152
|
+
throw new ProblemCandidateStoreError('invalid_input', 'sourceCandidateIds must contain at least one candidate');
|
|
153
|
+
}
|
|
154
|
+
const sourceCandidateIds = normalizeIdentifiers(input.sourceCandidateIds, 'sourceCandidateIds');
|
|
155
|
+
const { sourceCandidateIds: _sourceCandidateIds, ...candidateInput } = input;
|
|
156
|
+
void _sourceCandidateIds;
|
|
157
|
+
const candidate = normalizeProblemCandidate({ ...candidateInput, mergedFrom: sourceCandidateIds });
|
|
158
|
+
if (candidate.id && sourceCandidateIds.includes(candidate.id)) {
|
|
159
|
+
throw new ProblemCandidateStoreError('invalid_input', 'A merged candidate cannot include itself as a source');
|
|
160
|
+
}
|
|
161
|
+
const objectiveRecords = await this.readObjectives(candidate.objectiveRefs, context);
|
|
162
|
+
assertCandidateWrite(candidate, context);
|
|
163
|
+
let committed;
|
|
164
|
+
await this.mutateCatalog(async (catalog, current) => {
|
|
165
|
+
assertObjectivesAtAggregate(current, candidate.objectiveRefs, objectiveRecords, context);
|
|
166
|
+
await this.authorizeEvidence(candidate.event.sourceRefs, context, 'save');
|
|
167
|
+
const payloadDigest = digestCandidateInput(candidate);
|
|
168
|
+
const existingEvent = catalog.events.find((event) => event.eventId === candidate.event.id);
|
|
169
|
+
if (existingEvent !== undefined) {
|
|
170
|
+
if (existingEvent.payloadDigest !== payloadDigest) {
|
|
171
|
+
throw new ProblemCandidateStoreError('event_conflict', `Event ${candidate.event.id} was already ingested with a different payload`);
|
|
172
|
+
}
|
|
173
|
+
const existing = catalog.candidates.find((item) => item.id === existingEvent.candidateId);
|
|
174
|
+
if (!existing)
|
|
175
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Event ${candidate.event.id} points to a missing candidate`);
|
|
176
|
+
assertCandidateRead(existing, context);
|
|
177
|
+
await this.authorizeCandidateEvidence(catalog, existing, context, 'read');
|
|
178
|
+
committed = cloneCandidate(existing);
|
|
179
|
+
return catalog;
|
|
180
|
+
}
|
|
181
|
+
if (catalog.candidates.some((item) => item.id === candidate.id)) {
|
|
182
|
+
throw new ProblemCandidateStoreError('revision_conflict', `Candidate ${candidate.id} already exists`);
|
|
183
|
+
}
|
|
184
|
+
const sources = sourceCandidateIds.map((id) => {
|
|
185
|
+
const source = catalog.candidates.find((item) => item.id === id);
|
|
186
|
+
if (!source)
|
|
187
|
+
throw new ProblemCandidateStoreError('not_found', `Merge source candidate ${id} was not found`);
|
|
188
|
+
assertCandidateWrite(source, context);
|
|
189
|
+
assertCandidateScope(source, context);
|
|
190
|
+
if (source.status !== 'candidate') {
|
|
191
|
+
throw new ProblemCandidateStoreError('invalid_input', `Candidate ${id} is already ${source.status}`);
|
|
192
|
+
}
|
|
193
|
+
return source;
|
|
194
|
+
});
|
|
195
|
+
for (const source of sources) {
|
|
196
|
+
await this.authorizeCandidateEvidence(catalog, source, context, 'save');
|
|
197
|
+
}
|
|
198
|
+
const record = makeCandidateRecord(candidate, payloadDigest, sourceCandidateIds);
|
|
199
|
+
committed = record;
|
|
200
|
+
const mergedAt = candidate.event.recordedAt;
|
|
201
|
+
const sourceSet = new Set(sourceCandidateIds);
|
|
202
|
+
const candidates = catalog.candidates.map((source) => sourceSet.has(source.id)
|
|
203
|
+
? cloneCandidate({ ...source, status: 'merged', updatedAt: mergedAt })
|
|
204
|
+
: source);
|
|
205
|
+
// Keep the source resolution above as a deliberate assertion inside the lock.
|
|
206
|
+
void sources;
|
|
207
|
+
return {
|
|
208
|
+
version: PROBLEM_CANDIDATE_CATALOG_VERSION,
|
|
209
|
+
candidates: [...candidates, record],
|
|
210
|
+
events: [...catalog.events, { eventId: candidate.event.id, payloadDigest, candidateId: candidate.id }]
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
if (!committed)
|
|
214
|
+
throw new ProblemCandidateStoreError('readback_mismatch', 'Candidate merge did not produce a record');
|
|
215
|
+
const readback = await this.readCandidate(committed.id, context);
|
|
216
|
+
if (!readback)
|
|
217
|
+
throw new ProblemCandidateStoreError('readback_mismatch', `Candidate ${committed.id} was not readable after merge`);
|
|
218
|
+
return readback;
|
|
219
|
+
}
|
|
220
|
+
async readCandidate(id, context) {
|
|
221
|
+
assertContext(context);
|
|
222
|
+
const candidateId = requireIdentifier(id, 'id');
|
|
223
|
+
const catalog = await this.loadCatalog();
|
|
224
|
+
const candidate = catalog.candidates.find((item) => item.id === candidateId);
|
|
225
|
+
if (!candidate)
|
|
226
|
+
return null;
|
|
227
|
+
assertCandidateRead(candidate, context);
|
|
228
|
+
await this.authorizeCandidateEvidence(catalog, candidate, context, 'read');
|
|
229
|
+
await this.readObjectives(candidate.objectiveRefs, context);
|
|
230
|
+
return cloneCandidate(candidate);
|
|
231
|
+
}
|
|
232
|
+
async listCandidates(query, context) {
|
|
233
|
+
assertContext(context);
|
|
234
|
+
const normalizedQuery = normalizeQuery(query);
|
|
235
|
+
const catalog = await this.loadCatalog();
|
|
236
|
+
const visible = [];
|
|
237
|
+
for (const candidate of catalog.candidates) {
|
|
238
|
+
if (!matchesQuery(candidate, normalizedQuery))
|
|
239
|
+
continue;
|
|
240
|
+
try {
|
|
241
|
+
const readback = await this.readCandidate(candidate.id, context);
|
|
242
|
+
if (readback)
|
|
243
|
+
visible.push(readback);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
const code = errorCode(error);
|
|
247
|
+
if (code === 'authorization_denied' || code === 'scope_violation')
|
|
248
|
+
continue;
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return Object.freeze(visible);
|
|
253
|
+
}
|
|
254
|
+
async readObjectives(references, context) {
|
|
255
|
+
const records = [];
|
|
256
|
+
for (const reference of references) {
|
|
257
|
+
let record;
|
|
258
|
+
try {
|
|
259
|
+
record = await this.foundationStore.read(reference, context);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
throw mapFoundationError(error, reference);
|
|
263
|
+
}
|
|
264
|
+
if (!record) {
|
|
265
|
+
throw new ProblemCandidateStoreError('not_found', `Objective ${reference.id}@${reference.revision} was not found`);
|
|
266
|
+
}
|
|
267
|
+
if (record.definition.type !== 'objective') {
|
|
268
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Candidate references non-Objective ${reference.id}`);
|
|
269
|
+
}
|
|
270
|
+
if (record.digest !== reference.digest) {
|
|
271
|
+
throw new ProblemCandidateStoreError('revision_conflict', `Objective ${reference.id}@${reference.revision} digest does not match the candidate reference`);
|
|
272
|
+
}
|
|
273
|
+
records.push({ reference, digest: record.digest });
|
|
274
|
+
}
|
|
275
|
+
return records;
|
|
276
|
+
}
|
|
277
|
+
async authorizeCandidateEvidence(catalog, candidate, context, operation) {
|
|
278
|
+
const sources = collectCandidateSourceRefs(catalog, candidate);
|
|
279
|
+
await this.authorizeEvidence(sources, context, operation);
|
|
280
|
+
}
|
|
281
|
+
async authorizeEvidence(sources, context, operation) {
|
|
282
|
+
for (const source of sources) {
|
|
283
|
+
try {
|
|
284
|
+
const allowed = await this.evidenceAccessProvider.authorize({ operation, context, source });
|
|
285
|
+
if (allowed === false) {
|
|
286
|
+
throw new ProblemCandidateStoreError('authorization_denied', `Current evidence access was denied for ${source.kind}/${source.id}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error instanceof ProblemCandidateStoreError)
|
|
291
|
+
throw error;
|
|
292
|
+
throw new ProblemCandidateStoreError('authorization_denied', `Current evidence access could not be verified for ${source.kind}/${source.id}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async loadCatalog() {
|
|
297
|
+
try {
|
|
298
|
+
const os = await loadPersonalOs(this.dataDir);
|
|
299
|
+
if (os.graph.version !== 2) {
|
|
300
|
+
throw new ProblemCandidateStoreError('unsupported_graph', 'Problem candidates require canonical Graph v2');
|
|
301
|
+
}
|
|
302
|
+
return parseCatalog(await readPersonalOsSidecar(this.dataDir, PROBLEM_CANDIDATES_SIDECAR));
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error instanceof ProblemCandidateStoreError)
|
|
306
|
+
throw error;
|
|
307
|
+
throw new ProblemCandidateStoreError('corrupt_record', formatError(error));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async mutateCatalog(mutator) {
|
|
311
|
+
await mutatePersonalOsWithSidecar(this.dataDir, PROBLEM_CANDIDATES_SIDECAR, async (current, existingContent) => {
|
|
312
|
+
if (current.graph.version !== 2) {
|
|
313
|
+
throw new ProblemCandidateStoreError('unsupported_graph', 'Problem candidates require canonical Graph v2');
|
|
314
|
+
}
|
|
315
|
+
// The callback already owns the SSOT lock. Parse the supplied sidecar
|
|
316
|
+
// content instead of calling readPersonalOsSidecar or FoundationStore.
|
|
317
|
+
const catalog = parseCatalog(existingContent);
|
|
318
|
+
const next = await mutator(catalog, current);
|
|
319
|
+
assertCatalog(next);
|
|
320
|
+
return {
|
|
321
|
+
next: current,
|
|
322
|
+
sidecarContent: `${JSON.stringify(next, null, 2)}\n`,
|
|
323
|
+
result: undefined
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function assertObjectivesAtAggregate(current, references, expected, context) {
|
|
329
|
+
if (current.graph.version !== 2) {
|
|
330
|
+
throw new ProblemCandidateStoreError('unsupported_graph', 'Problem candidates require canonical Graph v2');
|
|
331
|
+
}
|
|
332
|
+
const catalog = foundationCatalogOrEmpty(current.graph.foundation);
|
|
333
|
+
for (const item of expected) {
|
|
334
|
+
const actual = findFoundationRecord(catalog, item.reference);
|
|
335
|
+
const latest = findLatestFoundationRecord(catalog, 'objective', item.reference.id);
|
|
336
|
+
if (!actual)
|
|
337
|
+
throw new ProblemCandidateStoreError('not_found', `Objective ${item.reference.id}@${item.reference.revision} disappeared before commit`);
|
|
338
|
+
if (!latest || latest.definition.type !== 'objective') {
|
|
339
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Objective ${item.reference.id} has no valid latest revision`);
|
|
340
|
+
}
|
|
341
|
+
if (actual.definition.type !== 'objective') {
|
|
342
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Candidate references non-Objective ${item.reference.id}`);
|
|
343
|
+
}
|
|
344
|
+
if (actual.digest !== item.digest || actual.digest !== item.reference.digest) {
|
|
345
|
+
throw new ProblemCandidateStoreError('revision_conflict', `Objective ${item.reference.id}@${item.reference.revision} changed before candidate commit`);
|
|
346
|
+
}
|
|
347
|
+
assertAclRead(latest.definition, context.principal);
|
|
348
|
+
assertScopeAccess(latest.definition, context);
|
|
349
|
+
}
|
|
350
|
+
// Keep this check explicit so callers cannot accidentally pass an expectation
|
|
351
|
+
// for a different Objective set when adding a future resolver.
|
|
352
|
+
if (references.length !== expected.length) {
|
|
353
|
+
throw new ProblemCandidateStoreError('corrupt_record', 'Objective resolution set is incomplete');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function makeCandidateRecord(input, payloadDigest, mergedFrom) {
|
|
357
|
+
const recordedAt = input.event.recordedAt;
|
|
358
|
+
return deepFreeze({
|
|
359
|
+
contractVersion: PROBLEM_CANDIDATES_CONTRACT_VERSION,
|
|
360
|
+
id: input.id,
|
|
361
|
+
kind: input.kind,
|
|
362
|
+
statement: input.statement,
|
|
363
|
+
objectiveRefs: input.objectiveRefs,
|
|
364
|
+
event: input.event,
|
|
365
|
+
observedGap: input.observedGap,
|
|
366
|
+
opportunity: input.opportunity,
|
|
367
|
+
threat: input.threat,
|
|
368
|
+
uncertainty: input.uncertainty,
|
|
369
|
+
deadline: input.deadline,
|
|
370
|
+
expectedEffect: input.expectedEffect,
|
|
371
|
+
requiredResources: input.requiredResources,
|
|
372
|
+
responsibleId: input.responsibleId,
|
|
373
|
+
ownerScope: input.ownerScope,
|
|
374
|
+
acl: input.acl,
|
|
375
|
+
status: 'candidate',
|
|
376
|
+
mergedFrom: [...mergedFrom],
|
|
377
|
+
createdAt: input.createdAt ?? recordedAt,
|
|
378
|
+
updatedAt: recordedAt,
|
|
379
|
+
provenance: {
|
|
380
|
+
eventIds: [input.event.id],
|
|
381
|
+
sourceRefs: input.event.sourceRefs,
|
|
382
|
+
candidateIds: [...mergedFrom]
|
|
383
|
+
},
|
|
384
|
+
payloadDigest
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
function digestCandidateInput(input) {
|
|
388
|
+
return `sha256:${createHash('sha256').update(canonicalJson({
|
|
389
|
+
id: input.id,
|
|
390
|
+
kind: input.kind,
|
|
391
|
+
statement: input.statement,
|
|
392
|
+
objectiveRefs: input.objectiveRefs,
|
|
393
|
+
event: input.event,
|
|
394
|
+
observedGap: input.observedGap,
|
|
395
|
+
opportunity: input.opportunity,
|
|
396
|
+
threat: input.threat,
|
|
397
|
+
uncertainty: input.uncertainty,
|
|
398
|
+
deadline: input.deadline,
|
|
399
|
+
expectedEffect: input.expectedEffect,
|
|
400
|
+
requiredResources: input.requiredResources,
|
|
401
|
+
responsibleId: input.responsibleId,
|
|
402
|
+
ownerScope: input.ownerScope,
|
|
403
|
+
acl: input.acl,
|
|
404
|
+
mergedFrom: input.mergedFrom ?? []
|
|
405
|
+
}), 'utf8').digest('hex')}`;
|
|
406
|
+
}
|
|
407
|
+
function parseCatalog(rawText) {
|
|
408
|
+
if (rawText === undefined)
|
|
409
|
+
return emptyCatalog();
|
|
410
|
+
try {
|
|
411
|
+
const raw = JSON.parse(rawText);
|
|
412
|
+
assertCatalog(raw);
|
|
413
|
+
return cloneCatalog(raw);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (error instanceof ProblemCandidateStoreError)
|
|
417
|
+
throw error;
|
|
418
|
+
throw new ProblemCandidateStoreError('corrupt_record', formatError(error));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function assertCatalog(value) {
|
|
422
|
+
if (!isRecord(value) || value.version !== PROBLEM_CANDIDATE_CATALOG_VERSION
|
|
423
|
+
|| !Array.isArray(value.candidates) || !Array.isArray(value.events)) {
|
|
424
|
+
throw new ProblemCandidateStoreError('corrupt_record', 'Problem candidate sidecar requires version 1 candidates and events arrays');
|
|
425
|
+
}
|
|
426
|
+
const candidateIds = new Set();
|
|
427
|
+
const eventIds = new Set();
|
|
428
|
+
for (const raw of value.candidates) {
|
|
429
|
+
const candidate = normalizeStoredCandidate(raw);
|
|
430
|
+
if (candidateIds.has(candidate.id))
|
|
431
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Duplicate candidate ${candidate.id}`);
|
|
432
|
+
candidateIds.add(candidate.id);
|
|
433
|
+
if (eventIds.has(candidate.event.id))
|
|
434
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Duplicate event ${candidate.event.id}`);
|
|
435
|
+
eventIds.add(candidate.event.id);
|
|
436
|
+
}
|
|
437
|
+
const ledgerEventIds = new Set();
|
|
438
|
+
const ledgerCandidateIds = new Set();
|
|
439
|
+
for (const raw of value.events) {
|
|
440
|
+
if (!isRecord(raw) || !isNonEmptyString(raw.eventId) || !isNonEmptyString(raw.candidateId) || !isDigest(raw.payloadDigest)) {
|
|
441
|
+
throw new ProblemCandidateStoreError('corrupt_record', 'Problem candidate event ledger entry is malformed');
|
|
442
|
+
}
|
|
443
|
+
if (ledgerEventIds.has(raw.eventId) || ledgerCandidateIds.has(raw.candidateId)) {
|
|
444
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Duplicate event ledger entry for ${raw.eventId}`);
|
|
445
|
+
}
|
|
446
|
+
ledgerEventIds.add(raw.eventId);
|
|
447
|
+
ledgerCandidateIds.add(raw.candidateId);
|
|
448
|
+
if (eventIds.has(raw.eventId)) {
|
|
449
|
+
const candidateRaw = value.candidates.find((item) => isRecord(item) && isRecord(item.event) && item.event.id === raw.eventId);
|
|
450
|
+
if (!isRecord(candidateRaw) || candidateRaw.id !== raw.candidateId || candidateRaw.payloadDigest !== raw.payloadDigest) {
|
|
451
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Event ledger mismatch for ${raw.eventId}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Event ledger ${raw.eventId} has no candidate`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (value.events.length !== value.candidates.length) {
|
|
459
|
+
throw new ProblemCandidateStoreError('corrupt_record', 'Every candidate event must have exactly one idempotency ledger entry');
|
|
460
|
+
}
|
|
461
|
+
if (ledgerEventIds.size !== eventIds.size || ledgerCandidateIds.size !== candidateIds.size) {
|
|
462
|
+
throw new ProblemCandidateStoreError('corrupt_record', 'Problem candidate event ledger is incomplete');
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function normalizeStoredCandidate(value) {
|
|
466
|
+
if (!isRecord(value))
|
|
467
|
+
throw new ProblemCandidateStoreError('corrupt_record', 'Problem candidate record is not an object');
|
|
468
|
+
assertOnlyKeys(value, [
|
|
469
|
+
'acl', 'contractVersion', 'createdAt', 'deadline', 'event', 'expectedEffect', 'id', 'kind',
|
|
470
|
+
'mergedFrom', 'observedGap', 'objectiveRefs', 'opportunity', 'ownerScope', 'provenance',
|
|
471
|
+
'payloadDigest', 'requiredResources', 'responsibleId', 'statement', 'status', 'threat',
|
|
472
|
+
'uncertainty', 'updatedAt'
|
|
473
|
+
], 'stored candidate');
|
|
474
|
+
if (value.contractVersion !== PROBLEM_CANDIDATES_CONTRACT_VERSION
|
|
475
|
+
|| !isDigest(value.payloadDigest)
|
|
476
|
+
|| !isIsoTimestamp(value.createdAt)
|
|
477
|
+
|| !isIsoTimestamp(value.updatedAt)
|
|
478
|
+
|| !Array.isArray(value.mergedFrom)
|
|
479
|
+
|| !isRecord(value.provenance)
|
|
480
|
+
|| !Array.isArray(value.provenance.eventIds)
|
|
481
|
+
|| !Array.isArray(value.provenance.sourceRefs)
|
|
482
|
+
|| !Array.isArray(value.provenance.candidateIds)) {
|
|
483
|
+
throw new ProblemCandidateStoreError('corrupt_record', 'Stored problem candidate metadata is malformed');
|
|
484
|
+
}
|
|
485
|
+
const input = normalizeProblemCandidate({
|
|
486
|
+
id: value.id,
|
|
487
|
+
kind: value.kind,
|
|
488
|
+
statement: value.statement,
|
|
489
|
+
objectiveRefs: value.objectiveRefs,
|
|
490
|
+
event: value.event,
|
|
491
|
+
observedGap: value.observedGap,
|
|
492
|
+
opportunity: value.opportunity,
|
|
493
|
+
threat: value.threat,
|
|
494
|
+
uncertainty: value.uncertainty,
|
|
495
|
+
deadline: value.deadline,
|
|
496
|
+
expectedEffect: value.expectedEffect,
|
|
497
|
+
requiredResources: value.requiredResources,
|
|
498
|
+
responsibleId: value.responsibleId,
|
|
499
|
+
ownerScope: value.ownerScope,
|
|
500
|
+
acl: value.acl,
|
|
501
|
+
mergedFrom: value.mergedFrom,
|
|
502
|
+
createdAt: value.createdAt,
|
|
503
|
+
// Stored records may be merged/dismissed. The input normalizer validates
|
|
504
|
+
// the candidate payload; the lifecycle status is checked separately below.
|
|
505
|
+
});
|
|
506
|
+
const status = normalizeCandidateStatus(value.status, 'status');
|
|
507
|
+
if (!isIsoTimestamp(value.updatedAt))
|
|
508
|
+
throw new ProblemCandidateStoreError('corrupt_record', 'updatedAt is invalid');
|
|
509
|
+
const sourceRefs = value.provenance.sourceRefs.map((source, index) => normalizeSourceRef(source, `provenance.sourceRefs[${index}]`));
|
|
510
|
+
const eventIds = normalizeIdentifiers(value.provenance.eventIds, 'provenance.eventIds');
|
|
511
|
+
const candidateIds = normalizeIdentifiers(value.provenance.candidateIds, 'provenance.candidateIds');
|
|
512
|
+
if (eventIds.length !== 1 || eventIds[0] !== input.event.id)
|
|
513
|
+
throw new ProblemCandidateStoreError('corrupt_record', 'Candidate provenance must retain its event id');
|
|
514
|
+
if (candidateIds.join('\u0000') !== (input.mergedFrom ?? []).join('\u0000'))
|
|
515
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Candidate ${input.id} merge provenance mismatch`);
|
|
516
|
+
if (canonicalJson(sourceRefs) !== canonicalJson(input.event.sourceRefs))
|
|
517
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Candidate ${input.id} source provenance mismatch`);
|
|
518
|
+
const payloadDigest = digestCandidateInput(input);
|
|
519
|
+
if (payloadDigest !== value.payloadDigest)
|
|
520
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Candidate ${input.id} payload digest mismatch`);
|
|
521
|
+
return deepFreeze({
|
|
522
|
+
contractVersion: PROBLEM_CANDIDATES_CONTRACT_VERSION,
|
|
523
|
+
...input,
|
|
524
|
+
status,
|
|
525
|
+
mergedFrom: input.mergedFrom ?? [],
|
|
526
|
+
createdAt: String(value.createdAt),
|
|
527
|
+
updatedAt: String(value.updatedAt),
|
|
528
|
+
provenance: { eventIds, sourceRefs, candidateIds },
|
|
529
|
+
payloadDigest
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
function cloneCatalog(catalog) {
|
|
533
|
+
return {
|
|
534
|
+
version: PROBLEM_CANDIDATE_CATALOG_VERSION,
|
|
535
|
+
candidates: catalog.candidates.map((candidate) => cloneCandidate(candidate)),
|
|
536
|
+
events: catalog.events.map((event) => ({ ...event }))
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function cloneCandidate(candidate) {
|
|
540
|
+
return deepFreeze(JSON.parse(JSON.stringify(candidate)));
|
|
541
|
+
}
|
|
542
|
+
function emptyCatalog() {
|
|
543
|
+
return { version: PROBLEM_CANDIDATE_CATALOG_VERSION, candidates: [], events: [] };
|
|
544
|
+
}
|
|
545
|
+
function collectCandidateSourceRefs(catalog, candidate) {
|
|
546
|
+
const refs = [];
|
|
547
|
+
const seenSources = new Set();
|
|
548
|
+
const visitedCandidates = new Set();
|
|
549
|
+
const visit = (current) => {
|
|
550
|
+
if (visitedCandidates.has(current.id))
|
|
551
|
+
return;
|
|
552
|
+
visitedCandidates.add(current.id);
|
|
553
|
+
for (const source of current.event.sourceRefs) {
|
|
554
|
+
const key = `${source.kind}:${source.id}:${source.digest ?? ''}`;
|
|
555
|
+
if (seenSources.has(key))
|
|
556
|
+
continue;
|
|
557
|
+
seenSources.add(key);
|
|
558
|
+
refs.push(source);
|
|
559
|
+
}
|
|
560
|
+
for (const sourceId of current.mergedFrom) {
|
|
561
|
+
const source = catalog.candidates.find((item) => item.id === sourceId);
|
|
562
|
+
if (!source) {
|
|
563
|
+
throw new ProblemCandidateStoreError('corrupt_record', `Candidate ${current.id} points to missing merge source ${sourceId}`);
|
|
564
|
+
}
|
|
565
|
+
visit(source);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
visit(candidate);
|
|
569
|
+
return refs;
|
|
570
|
+
}
|
|
571
|
+
function normalizeEvent(value) {
|
|
572
|
+
if (!isRecord(value))
|
|
573
|
+
throw invalid('event must be an object');
|
|
574
|
+
assertOnlyKeys(value, ['id', 'occurredAt', 'recordedAt', 'sourceRefs'], 'event');
|
|
575
|
+
const id = requireIdentifier(value.id, 'event.id');
|
|
576
|
+
const occurredAt = requireIsoTimestamp(value.occurredAt, 'event.occurredAt');
|
|
577
|
+
const recordedAt = requireIsoTimestamp(value.recordedAt, 'event.recordedAt');
|
|
578
|
+
if (Date.parse(recordedAt) < Date.parse(occurredAt))
|
|
579
|
+
throw invalid('event.recordedAt cannot precede event.occurredAt');
|
|
580
|
+
if (!Array.isArray(value.sourceRefs) || value.sourceRefs.length === 0)
|
|
581
|
+
throw invalid('event.sourceRefs must contain at least one source reference');
|
|
582
|
+
const sourceRefs = value.sourceRefs.map((source, index) => normalizeSourceRef(source, `event.sourceRefs[${index}]`));
|
|
583
|
+
assertUnique(sourceRefs.map((source) => `${source.kind}:${source.id}`), 'event.sourceRefs');
|
|
584
|
+
return deepFreeze({ id, occurredAt, recordedAt, sourceRefs });
|
|
585
|
+
}
|
|
586
|
+
function normalizeObjectiveReference(value, label) {
|
|
587
|
+
if (!isRecord(value)
|
|
588
|
+
|| value.type !== 'objective'
|
|
589
|
+
|| !isNonEmptyString(value.id)
|
|
590
|
+
|| !isPositiveRevision(value.revision)
|
|
591
|
+
|| !isDigest(value.digest)) {
|
|
592
|
+
throw invalid(`${label} must contain objective id, positive revision, and sha256 digest`);
|
|
593
|
+
}
|
|
594
|
+
return deepFreeze({ id: String(value.id).trim(), type: 'objective', revision: String(value.revision), digest: value.digest });
|
|
595
|
+
}
|
|
596
|
+
function normalizeSourceRef(value, label) {
|
|
597
|
+
if (!isRecord(value))
|
|
598
|
+
throw invalid(`${label} must be an object`);
|
|
599
|
+
assertOnlyKeys(value, ['digest', 'id', 'kind'], label);
|
|
600
|
+
if (!isNonEmptyString(value.id) || !isSourceKind(value.kind) || (value.digest !== undefined && !isDigest(value.digest))) {
|
|
601
|
+
throw invalid(`${label} must contain a source id, supported kind, and optional sha256 digest`);
|
|
602
|
+
}
|
|
603
|
+
return deepFreeze({
|
|
604
|
+
id: String(value.id).trim(),
|
|
605
|
+
kind: value.kind,
|
|
606
|
+
...(value.digest === undefined ? {} : { digest: value.digest })
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
function normalizeTextField(value, label) {
|
|
610
|
+
if (!isRecord(value) || !['known', 'unknown'].includes(String(value.status)))
|
|
611
|
+
throw invalid(`${label} must be known or unknown`);
|
|
612
|
+
assertOnlyKeys(value, ['status', 'value', 'reason'], label);
|
|
613
|
+
if (value.status === 'unknown')
|
|
614
|
+
return deepFreeze({ status: 'unknown', reason: requireText(value.reason, `${label}.reason`) });
|
|
615
|
+
if (!isNonEmptyString(value.value))
|
|
616
|
+
throw invalid(`${label}.value must be non-empty when known`);
|
|
617
|
+
return deepFreeze({ status: 'known', value: String(value.value) });
|
|
618
|
+
}
|
|
619
|
+
function normalizeDeadlineField(value, label) {
|
|
620
|
+
if (!isRecord(value) || !['known', 'unknown'].includes(String(value.status)))
|
|
621
|
+
throw invalid(`${label} must be known or unknown`);
|
|
622
|
+
assertOnlyKeys(value, ['status', 'value', 'reason'], label);
|
|
623
|
+
if (value.status === 'unknown')
|
|
624
|
+
return deepFreeze({ status: 'unknown', reason: requireText(value.reason, `${label}.reason`) });
|
|
625
|
+
if (!isRecord(value.value))
|
|
626
|
+
throw invalid(`${label}.value must be an object when known`);
|
|
627
|
+
assertOnlyKeys(value.value, ['dueAt', 'evaluationAt'], `${label}.value`);
|
|
628
|
+
const dueAt = requireIsoTimestamp(value.value.dueAt, `${label}.value.dueAt`);
|
|
629
|
+
const evaluationAt = value.value.evaluationAt === undefined
|
|
630
|
+
? undefined
|
|
631
|
+
: requireIsoTimestamp(value.value.evaluationAt, `${label}.value.evaluationAt`);
|
|
632
|
+
return deepFreeze({ status: 'known', value: { dueAt, ...(evaluationAt === undefined ? {} : { evaluationAt }) } });
|
|
633
|
+
}
|
|
634
|
+
function normalizeResourceField(value, label) {
|
|
635
|
+
if (!isRecord(value) || !['known', 'unknown'].includes(String(value.status)))
|
|
636
|
+
throw invalid(`${label} must be known or unknown`);
|
|
637
|
+
assertOnlyKeys(value, ['status', 'value', 'reason'], label);
|
|
638
|
+
if (value.status === 'unknown')
|
|
639
|
+
return deepFreeze({ status: 'unknown', reason: requireText(value.reason, `${label}.reason`) });
|
|
640
|
+
if (!Array.isArray(value.value))
|
|
641
|
+
throw invalid(`${label}.value must be an array when known`);
|
|
642
|
+
const resources = value.value.map((resource, index) => {
|
|
643
|
+
if (!isRecord(resource))
|
|
644
|
+
throw invalid(`${label}.value[${index}] must be an object`);
|
|
645
|
+
assertOnlyKeys(resource, ['id', 'kind', 'quantity', 'unit'], `${label}.value[${index}]`);
|
|
646
|
+
if (!isNonEmptyString(resource.id) || !isNonEmptyString(resource.kind))
|
|
647
|
+
throw invalid(`${label}.value[${index}] id and kind are required`);
|
|
648
|
+
if (resource.quantity !== undefined && (typeof resource.quantity !== 'number' || !Number.isFinite(resource.quantity) || resource.quantity < 0)) {
|
|
649
|
+
throw invalid(`${label}.value[${index}].quantity must be a finite non-negative number`);
|
|
650
|
+
}
|
|
651
|
+
if (resource.unit !== undefined && !isNonEmptyString(resource.unit))
|
|
652
|
+
throw invalid(`${label}.value[${index}].unit must be non-empty`);
|
|
653
|
+
return {
|
|
654
|
+
id: String(resource.id).trim(),
|
|
655
|
+
kind: String(resource.kind).trim(),
|
|
656
|
+
...(resource.quantity === undefined ? {} : { quantity: resource.quantity }),
|
|
657
|
+
...(resource.unit === undefined ? {} : { unit: String(resource.unit).trim() })
|
|
658
|
+
};
|
|
659
|
+
});
|
|
660
|
+
assertUnique(resources.map((resource) => `${resource.kind}:${resource.id}`), `${label}.value`);
|
|
661
|
+
return deepFreeze({ status: 'known', value: resources });
|
|
662
|
+
}
|
|
663
|
+
function normalizeScope(value, label, requireSubjects) {
|
|
664
|
+
if (!isRecord(value) || !Array.isArray(value.subjectIds) || !isIsoTimestamp(value.validFrom)
|
|
665
|
+
|| (value.validUntil !== undefined && !isIsoTimestamp(value.validUntil))) {
|
|
666
|
+
throw invalid(`${label} is invalid`);
|
|
667
|
+
}
|
|
668
|
+
const subjectIds = normalizeIdentifiers(value.subjectIds, `${label}.subjectIds`);
|
|
669
|
+
if (requireSubjects && subjectIds.length === 0)
|
|
670
|
+
throw invalid(`${label}.subjectIds must contain at least one subject`);
|
|
671
|
+
if (value.validUntil !== undefined && Date.parse(value.validUntil) < Date.parse(value.validFrom))
|
|
672
|
+
throw invalid(`${label}.validUntil precedes validFrom`);
|
|
673
|
+
return deepFreeze({ subjectIds, validFrom: value.validFrom, ...(value.validUntil === undefined ? {} : { validUntil: value.validUntil }) });
|
|
674
|
+
}
|
|
675
|
+
function normalizeAcl(value, label) {
|
|
676
|
+
if (!isRecord(value) || !isNonEmptyString(value.ownerId)
|
|
677
|
+
|| !['private', 'project', 'organization', 'public'].includes(String(value.visibility))
|
|
678
|
+
|| !Array.isArray(value.readerIds) || !Array.isArray(value.writerIds)) {
|
|
679
|
+
throw invalid(`${label} is invalid`);
|
|
680
|
+
}
|
|
681
|
+
const readerIds = normalizeIdentifiers(value.readerIds, `${label}.readerIds`);
|
|
682
|
+
const writerIds = normalizeIdentifiers(value.writerIds, `${label}.writerIds`);
|
|
683
|
+
const visibility = value.visibility;
|
|
684
|
+
return deepFreeze({ ownerId: String(value.ownerId).trim(), visibility, readerIds, writerIds });
|
|
685
|
+
}
|
|
686
|
+
function normalizeQuery(query) {
|
|
687
|
+
if (query === undefined)
|
|
688
|
+
return {};
|
|
689
|
+
if (!isRecord(query))
|
|
690
|
+
throw invalid('query must be an object');
|
|
691
|
+
assertOnlyKeys(query, ['ownerScope', 'responsibleId', 'status'], 'query');
|
|
692
|
+
return {
|
|
693
|
+
...(query.ownerScope === undefined ? {} : { ownerScope: normalizeScope(query.ownerScope, 'query.ownerScope', true) }),
|
|
694
|
+
...(query.responsibleId === undefined ? {} : { responsibleId: requireIdentifier(query.responsibleId, 'query.responsibleId') }),
|
|
695
|
+
...(query.status === undefined ? {} : { status: normalizeCandidateStatus(query.status, 'query.status') })
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
function matchesQuery(candidate, query) {
|
|
699
|
+
if (query.status !== undefined && candidate.status !== query.status)
|
|
700
|
+
return false;
|
|
701
|
+
if (query.responsibleId !== undefined && (candidate.responsibleId.status !== 'known' || candidate.responsibleId.value !== query.responsibleId))
|
|
702
|
+
return false;
|
|
703
|
+
if (query.ownerScope !== undefined && !scopeContains(query.ownerScope, candidate.ownerScope))
|
|
704
|
+
return false;
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
function assertCandidateWrite(candidate, context) {
|
|
708
|
+
if (!aclAllowsWrite(candidate.acl, context.principal))
|
|
709
|
+
throw new ProblemCandidateStoreError('authorization_denied', `Principal ${context.principal} cannot write candidate ${candidate.id}`);
|
|
710
|
+
assertCandidateScope(candidate, context);
|
|
711
|
+
}
|
|
712
|
+
function assertCandidateRead(candidate, context) {
|
|
713
|
+
if (!aclAllowsRead(candidate.acl, context.principal))
|
|
714
|
+
throw new ProblemCandidateStoreError('authorization_denied', `Principal ${context.principal} cannot read candidate ${candidate.id}`);
|
|
715
|
+
assertCandidateScope(candidate, context);
|
|
716
|
+
}
|
|
717
|
+
function assertCandidateScope(candidate, context) {
|
|
718
|
+
if (context.scope !== undefined && !scopeContains(context.scope, candidate.ownerScope)) {
|
|
719
|
+
throw new ProblemCandidateStoreError('scope_violation', `Candidate is outside the trusted scope for ${context.principal}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function assertAclRead(definition, principal) {
|
|
723
|
+
if (!aclAllowsRead(definition.acl, principal))
|
|
724
|
+
throw new ProblemCandidateStoreError('authorization_denied', `Principal ${principal} cannot read Objective ${definition.id}`);
|
|
725
|
+
}
|
|
726
|
+
function assertScopeAccess(definition, context) {
|
|
727
|
+
if (context.scope !== undefined && !scopeContains(context.scope, definition.scope)) {
|
|
728
|
+
throw new ProblemCandidateStoreError('scope_violation', `Objective ${definition.id} is outside the trusted scope`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function scopeContains(container, target) {
|
|
732
|
+
const allowed = new Set(container.subjectIds);
|
|
733
|
+
if (target.subjectIds.length === 0 || target.subjectIds.some((subjectId) => !allowed.has(subjectId)))
|
|
734
|
+
return false;
|
|
735
|
+
if (Date.parse(target.validFrom) < Date.parse(container.validFrom))
|
|
736
|
+
return false;
|
|
737
|
+
if (container.validUntil !== undefined) {
|
|
738
|
+
if (target.validUntil === undefined || Date.parse(target.validUntil) > Date.parse(container.validUntil))
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
function aclAllowsWrite(acl, principal) {
|
|
744
|
+
return acl.ownerId === principal || acl.writerIds.includes(principal);
|
|
745
|
+
}
|
|
746
|
+
function aclAllowsRead(acl, principal) {
|
|
747
|
+
return acl.visibility === 'public'
|
|
748
|
+
|| acl.ownerId === principal
|
|
749
|
+
|| acl.readerIds.includes(principal)
|
|
750
|
+
|| acl.writerIds.includes(principal);
|
|
751
|
+
}
|
|
752
|
+
function mapFoundationError(error, reference) {
|
|
753
|
+
const code = errorCode(error);
|
|
754
|
+
if (code === 'authorization_denied')
|
|
755
|
+
return new ProblemCandidateStoreError('authorization_denied', `Objective ${reference.id}@${reference.revision} is not readable`);
|
|
756
|
+
if (code === 'scope_violation')
|
|
757
|
+
return new ProblemCandidateStoreError('scope_violation', `Objective ${reference.id}@${reference.revision} is outside the trusted scope`);
|
|
758
|
+
if (code === 'not_found')
|
|
759
|
+
return new ProblemCandidateStoreError('not_found', `Objective ${reference.id}@${reference.revision} was not found`);
|
|
760
|
+
return new ProblemCandidateStoreError('corrupt_record', `Objective resolver failed for ${reference.id}@${reference.revision}: ${formatError(error)}`);
|
|
761
|
+
}
|
|
762
|
+
function assertContext(context) {
|
|
763
|
+
if (!context || !isNonEmptyString(context.principal))
|
|
764
|
+
throw new ProblemCandidateStoreError('authorization_denied', 'A trusted principal is required');
|
|
765
|
+
if (context.scope !== undefined)
|
|
766
|
+
normalizeScope(context.scope, 'context.scope', false);
|
|
767
|
+
}
|
|
768
|
+
function normalizeCandidateStatus(value, label) {
|
|
769
|
+
if (value !== 'candidate' && value !== 'merged' && value !== 'dismissed')
|
|
770
|
+
throw invalid(`${label} must be candidate, merged, or dismissed`);
|
|
771
|
+
return value;
|
|
772
|
+
}
|
|
773
|
+
function requireCandidateKind(value, label) {
|
|
774
|
+
if (value !== 'gap' && value !== 'opportunity' && value !== 'threat' && value !== 'uncertainty') {
|
|
775
|
+
throw invalid(`${label} must be gap, opportunity, threat, or uncertainty`);
|
|
776
|
+
}
|
|
777
|
+
return value;
|
|
778
|
+
}
|
|
779
|
+
function isSourceKind(value) {
|
|
780
|
+
return value === 'event' || value === 'observation' || value === 'document' || value === 'decision'
|
|
781
|
+
|| value === 'import' || value === 'candidate' || value === 'evidence';
|
|
782
|
+
}
|
|
783
|
+
function normalizeIdentifiers(value, label) {
|
|
784
|
+
if (!Array.isArray(value))
|
|
785
|
+
throw invalid(`${label} must be an array`);
|
|
786
|
+
const identifiers = value.map((item, index) => requireIdentifier(item, `${label}[${index}]`));
|
|
787
|
+
assertUnique(identifiers, label);
|
|
788
|
+
return identifiers;
|
|
789
|
+
}
|
|
790
|
+
function assertUnique(values, label) {
|
|
791
|
+
if (new Set(values).size !== values.length)
|
|
792
|
+
throw invalid(`${label} must not contain duplicates`);
|
|
793
|
+
}
|
|
794
|
+
function assertOnlyKeys(value, allowed, label) {
|
|
795
|
+
const allowedSet = new Set(allowed);
|
|
796
|
+
const unexpected = Object.keys(value).filter((key) => !allowedSet.has(key));
|
|
797
|
+
if (unexpected.length > 0)
|
|
798
|
+
throw invalid(`${label} contains unsupported field(s): ${unexpected.join(', ')}`);
|
|
799
|
+
}
|
|
800
|
+
function requireIdentifier(value, label) {
|
|
801
|
+
if (!isNonEmptyString(value) || value.includes('\0'))
|
|
802
|
+
throw invalid(`${label} must be a non-empty identifier`);
|
|
803
|
+
return value.trim();
|
|
804
|
+
}
|
|
805
|
+
function requireText(value, label) {
|
|
806
|
+
if (!isNonEmptyString(value))
|
|
807
|
+
throw invalid(`${label} must be non-empty text`);
|
|
808
|
+
return value.trim();
|
|
809
|
+
}
|
|
810
|
+
function requireIsoTimestamp(value, label) {
|
|
811
|
+
if (!isIsoTimestamp(value))
|
|
812
|
+
throw invalid(`${label} must be an ISO timestamp`);
|
|
813
|
+
return value;
|
|
814
|
+
}
|
|
815
|
+
function isIsoTimestamp(value) {
|
|
816
|
+
if (typeof value !== 'string')
|
|
817
|
+
return false;
|
|
818
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
|
|
819
|
+
if (!match)
|
|
820
|
+
return false;
|
|
821
|
+
const [, yearText, monthText, dayText, hourText, minuteText, secondText, , zone, offsetHourText, offsetMinuteText] = match;
|
|
822
|
+
const year = Number(yearText);
|
|
823
|
+
const month = Number(monthText);
|
|
824
|
+
const day = Number(dayText);
|
|
825
|
+
const hour = Number(hourText);
|
|
826
|
+
const minute = Number(minuteText);
|
|
827
|
+
const second = Number(secondText);
|
|
828
|
+
const offsetHour = zone === 'Z' ? 0 : Number(offsetHourText);
|
|
829
|
+
const offsetMinute = zone === 'Z' ? 0 : Number(offsetMinuteText);
|
|
830
|
+
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
831
|
+
const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0;
|
|
832
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth
|
|
833
|
+
&& hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 && second >= 0 && second <= 59
|
|
834
|
+
&& offsetHour >= 0 && offsetHour <= 23 && offsetMinute >= 0 && offsetMinute <= 59
|
|
835
|
+
&& Number.isFinite(Date.parse(value));
|
|
836
|
+
}
|
|
837
|
+
function isPositiveRevision(value) {
|
|
838
|
+
return typeof value === 'string' && /^[1-9]\d*$/.test(value);
|
|
839
|
+
}
|
|
840
|
+
function isDigest(value) {
|
|
841
|
+
return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value);
|
|
842
|
+
}
|
|
843
|
+
function isNonEmptyString(value) {
|
|
844
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
845
|
+
}
|
|
846
|
+
function isRecord(value) {
|
|
847
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
848
|
+
}
|
|
849
|
+
function invalid(message) {
|
|
850
|
+
return new ProblemCandidateStoreError('invalid_input', message);
|
|
851
|
+
}
|
|
852
|
+
function errorCode(error) {
|
|
853
|
+
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
|
854
|
+
? error.code
|
|
855
|
+
: undefined;
|
|
856
|
+
}
|
|
857
|
+
function formatError(error) {
|
|
858
|
+
return error instanceof Error ? error.message : String(error);
|
|
859
|
+
}
|
|
860
|
+
function canonicalJson(value) {
|
|
861
|
+
const serialized = JSON.stringify(canonicalValue(value));
|
|
862
|
+
if (serialized === undefined)
|
|
863
|
+
throw invalid('candidate values must be JSON-compatible');
|
|
864
|
+
return serialized;
|
|
865
|
+
}
|
|
866
|
+
function canonicalValue(value, active = new Set()) {
|
|
867
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
868
|
+
return value;
|
|
869
|
+
if (typeof value === 'number') {
|
|
870
|
+
if (!Number.isFinite(value))
|
|
871
|
+
throw invalid('candidate values must contain finite numbers');
|
|
872
|
+
return Object.is(value, -0) ? 0 : value;
|
|
873
|
+
}
|
|
874
|
+
if (typeof value !== 'object')
|
|
875
|
+
throw invalid('candidate values must be JSON-compatible');
|
|
876
|
+
if (active.has(value))
|
|
877
|
+
throw invalid('candidate values must not be cyclic');
|
|
878
|
+
active.add(value);
|
|
879
|
+
try {
|
|
880
|
+
if (Array.isArray(value))
|
|
881
|
+
return value.map((item) => canonicalValue(item, active));
|
|
882
|
+
const result = {};
|
|
883
|
+
for (const key of Object.keys(value).sort())
|
|
884
|
+
result[key] = canonicalValue(value[key], active);
|
|
885
|
+
return result;
|
|
886
|
+
}
|
|
887
|
+
finally {
|
|
888
|
+
active.delete(value);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
function deepFreeze(value) {
|
|
892
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
893
|
+
Object.freeze(value);
|
|
894
|
+
for (const child of Object.values(value))
|
|
895
|
+
deepFreeze(child);
|
|
896
|
+
}
|
|
897
|
+
return value;
|
|
898
|
+
}
|