@unson/brainbase-mcp 0.6.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.
Files changed (80) hide show
  1. package/dist/canonical-task-contract.d.ts +9 -0
  2. package/dist/canonical-task-contract.js +39 -0
  3. package/dist/canonical-task-principal.d.ts +16 -0
  4. package/dist/canonical-task-principal.js +36 -0
  5. package/dist/canonical-task-service.d.ts +251 -0
  6. package/dist/canonical-task-service.js +809 -0
  7. package/dist/company-os-evaluation.d.ts +193 -0
  8. package/dist/company-os-evaluation.js +1150 -0
  9. package/dist/company-os-impact-review.d.ts +248 -0
  10. package/dist/company-os-impact-review.js +663 -0
  11. package/dist/company-os-learning-adoption.d.ts +288 -0
  12. package/dist/company-os-learning-adoption.js +944 -0
  13. package/dist/company-os-objectives.d.ts +52 -0
  14. package/dist/company-os-objectives.js +197 -0
  15. package/dist/company-os-receipt-adapter.d.ts +109 -0
  16. package/dist/company-os-receipt-adapter.js +636 -0
  17. package/dist/constraint-exception-store.d.ts +23 -0
  18. package/dist/constraint-exception-store.js +108 -0
  19. package/dist/constraint-resolution.d.ts +175 -0
  20. package/dist/constraint-resolution.js +922 -0
  21. package/dist/decision-adapter.d.ts +145 -0
  22. package/dist/decision-adapter.js +586 -0
  23. package/dist/durable-wait-http.d.ts +51 -0
  24. package/dist/durable-wait-http.js +497 -0
  25. package/dist/durable-waits.d.ts +234 -0
  26. package/dist/durable-waits.js +944 -0
  27. package/dist/execution-authority-http.d.ts +28 -0
  28. package/dist/execution-authority-http.js +359 -0
  29. package/dist/execution-authority.d.ts +201 -0
  30. package/dist/execution-authority.js +634 -0
  31. package/dist/foundation-catalog.d.ts +26 -0
  32. package/dist/foundation-catalog.js +206 -0
  33. package/dist/foundation-constraint-store.d.ts +50 -0
  34. package/dist/foundation-constraint-store.js +215 -0
  35. package/dist/foundation-http.d.ts +90 -0
  36. package/dist/foundation-http.js +439 -0
  37. package/dist/foundation-store.d.ts +132 -0
  38. package/dist/foundation-store.js +654 -0
  39. package/dist/judgment-dag-composition.d.ts +224 -0
  40. package/dist/judgment-dag-composition.js +731 -0
  41. package/dist/judgment-dag.d.ts +2 -0
  42. package/dist/judgment-dag.js +1 -0
  43. package/dist/judgment-problem-snapshot.d.ts +185 -0
  44. package/dist/judgment-problem-snapshot.js +777 -0
  45. package/dist/judgment-view-http.d.ts +22 -0
  46. package/dist/judgment-view-http.js +128 -0
  47. package/dist/judgment-view.d.ts +222 -0
  48. package/dist/judgment-view.js +627 -0
  49. package/dist/knowledge-adapter.d.ts +112 -0
  50. package/dist/knowledge-adapter.js +691 -0
  51. package/dist/ontology-foundation.d.ts +209 -0
  52. package/dist/ontology-foundation.js +1000 -0
  53. package/dist/ontology.d.ts +13 -0
  54. package/dist/ontology.js +14 -0
  55. package/dist/organization-connection.d.ts +164 -0
  56. package/dist/organization-connection.js +428 -0
  57. package/dist/personal-knowledge.d.ts +120 -0
  58. package/dist/personal-knowledge.js +750 -0
  59. package/dist/problem-candidates.d.ts +149 -0
  60. package/dist/problem-candidates.js +898 -0
  61. package/dist/problem-selection.d.ts +238 -0
  62. package/dist/problem-selection.js +995 -0
  63. package/dist/resource-reservation-http.d.ts +31 -0
  64. package/dist/resource-reservation-http.js +371 -0
  65. package/dist/resource-reservations.d.ts +221 -0
  66. package/dist/resource-reservations.js +577 -0
  67. package/dist/server.d.ts +97 -0
  68. package/dist/server.js +180 -1
  69. package/dist/ssot.d.ts +6 -1
  70. package/dist/ssot.js +63 -5
  71. package/dist/streamable-http.d.ts +42 -0
  72. package/dist/streamable-http.js +288 -0
  73. package/dist/types.d.ts +20 -0
  74. package/dist/world-model.d.ts +181 -0
  75. package/dist/world-model.js +873 -0
  76. package/package.json +139 -3
  77. package/ui/judgment-view.css +238 -0
  78. package/ui/judgment-view.js +508 -0
  79. package/ui/objective-editor.css +180 -0
  80. package/ui/objective-editor.js +1139 -0
@@ -0,0 +1,809 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canTransitionCanonicalTaskStatus, hasInvalidCanonicalTaskProjectCode, isCanonicalTaskPriority, isCanonicalTaskStatus, normalizeCanonicalTaskProjectCodes, } from './canonical-task-contract.js';
3
+ import { normalizeCanonicalTaskPrincipal, principalNamespace, } from './canonical-task-principal.js';
4
+ export class CanonicalTaskError extends Error {
5
+ code;
6
+ status;
7
+ details;
8
+ fieldErrors;
9
+ currentTask;
10
+ constructor(code, message, status = 400, details = {}, options = {}) {
11
+ super(message);
12
+ this.name = 'CanonicalTaskError';
13
+ this.code = code;
14
+ this.status = status;
15
+ this.details = details;
16
+ this.fieldErrors = options.fieldErrors
17
+ ?? details.fieldErrors
18
+ ?? {};
19
+ this.currentTask = options.currentTask
20
+ ?? details.currentTask;
21
+ }
22
+ }
23
+ const MAX_TITLE_LENGTH = 200;
24
+ const MAX_SEARCH_LENGTH = 200;
25
+ const DEFAULT_LIMIT = 20;
26
+ const MAX_LIMIT = 100;
27
+ const RESERVED_IDEMPOTENCY_PREFIXES = ['api:', 'workflow:'];
28
+ const CREATE_FIELDS = new Set([
29
+ 'title',
30
+ 'description',
31
+ 'priority',
32
+ 'assignee_person_id',
33
+ 'due_at',
34
+ 'waiting_on',
35
+ 'review_at',
36
+ 'source_refs',
37
+ 'project_codes',
38
+ ]);
39
+ const MUTABLE_FIELDS = new Set([
40
+ 'expected_version',
41
+ 'title',
42
+ 'description',
43
+ 'priority',
44
+ 'assignee_person_id',
45
+ 'due_at',
46
+ 'waiting_on',
47
+ 'review_at',
48
+ 'source_refs',
49
+ 'project_codes',
50
+ ]);
51
+ function fail(code, message, status = 400, details = {}) {
52
+ throw new CanonicalTaskError(code, message, status, details);
53
+ }
54
+ function failValidation(fieldErrors) {
55
+ throw new CanonicalTaskError('validation_failed', 'Canonical Task request is invalid', 422, { fieldErrors }, { fieldErrors });
56
+ }
57
+ function normalizeString(value, field, options = {}) {
58
+ const normalized = value == null ? '' : String(value).normalize('NFKC').trim();
59
+ if (!normalized) {
60
+ if (options.required)
61
+ fail('validation_error', `${field} is required`, 400, { field });
62
+ return null;
63
+ }
64
+ if (/[\u0000-\u001f\u007f]/u.test(normalized)) {
65
+ fail('validation_error', `${field} contains control characters`, 400, { field });
66
+ }
67
+ if (options.max && normalized.length > options.max) {
68
+ fail('validation_error', `${field} exceeds the maximum length`, 400, { field, max: options.max });
69
+ }
70
+ return normalized;
71
+ }
72
+ function normalizeIsoDate(value, field) {
73
+ const text = normalizeString(value, field);
74
+ if (text == null)
75
+ return null;
76
+ const date = new Date(text);
77
+ if (Number.isNaN(date.getTime()))
78
+ fail('validation_error', `${field} must be an ISO date`, 400, { field });
79
+ return date.toISOString();
80
+ }
81
+ function normalizeLimit(value, max = MAX_LIMIT) {
82
+ if (value == null || value === '')
83
+ return DEFAULT_LIMIT;
84
+ const limit = Number(value);
85
+ if (!Number.isInteger(limit) || limit < 1 || limit > max) {
86
+ failValidation({ limit: [`must_be_between_1_and_${max}`] });
87
+ }
88
+ return limit;
89
+ }
90
+ function normalizeCursor(value) {
91
+ return normalizeString(value, 'cursor', { max: 500 });
92
+ }
93
+ function normalizeCollection(value, field) {
94
+ if (value == null || value === '')
95
+ return undefined;
96
+ const values = Array.isArray(value) ? value : [value];
97
+ const normalized = values.flatMap((item) => String(item).split(','))
98
+ .map((item) => item.normalize('NFKC').trim())
99
+ .filter(Boolean);
100
+ if (normalized.length === 0)
101
+ return undefined;
102
+ if (normalized.some((item) => /[\u0000-\u001f\u007f]/u.test(item))) {
103
+ fail('validation_error', `${field} contains control characters`, 400, { field });
104
+ }
105
+ return [...new Set(normalized)];
106
+ }
107
+ function normalizeProjectCodes(value) {
108
+ if (value == null || value === '')
109
+ return undefined;
110
+ if (hasInvalidCanonicalTaskProjectCode(value)) {
111
+ failValidation({ project_codes: ['invalid_project_code'] });
112
+ }
113
+ return normalizeCanonicalTaskProjectCodes(value);
114
+ }
115
+ function normalizeSourceRefs(value) {
116
+ if (value == null)
117
+ return undefined;
118
+ if (!Array.isArray(value))
119
+ fail('validation_error', 'source_refs must be an array', 400, { field: 'source_refs' });
120
+ return value;
121
+ }
122
+ function canonicalize(value) {
123
+ if (Array.isArray(value))
124
+ return value.map(canonicalize);
125
+ if (value && typeof value === 'object') {
126
+ return Object.fromEntries(Object.entries(value)
127
+ .filter(([, entry]) => entry !== undefined)
128
+ .sort(([left], [right]) => left.localeCompare(right))
129
+ .map(([key, entry]) => [key, canonicalize(entry)]));
130
+ }
131
+ return value;
132
+ }
133
+ function fingerprint(value) {
134
+ return createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex');
135
+ }
136
+ function validHttpUrl(value) {
137
+ if (typeof value !== 'string' || !value.trim())
138
+ return null;
139
+ try {
140
+ const url = new URL(value);
141
+ return ['http:', 'https:'].includes(url.protocol) ? url.toString() : null;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ function operationKey(principal, clientKey, prefix) {
148
+ return `${prefix}${principalNamespace(principal)}:${clientKey}`;
149
+ }
150
+ function extractPersistedFingerprint(task) {
151
+ const value = task.payload_fingerprint ?? task._payload_fingerprint;
152
+ return typeof value === 'string' ? value : null;
153
+ }
154
+ function projectTaskResult(result) {
155
+ if (!result || typeof result !== 'object')
156
+ return result;
157
+ const candidate = result;
158
+ if (typeof candidate.id === 'string' && Number.isInteger(candidate.version)) {
159
+ return { task_id: candidate.id, task_version: candidate.version };
160
+ }
161
+ if (typeof candidate.task_id === 'string' && Number.isInteger(candidate.version)) {
162
+ return { task_id: candidate.task_id, task_version: candidate.version };
163
+ }
164
+ return result;
165
+ }
166
+ function normalizePage(page, normalize) {
167
+ const items = Array.isArray(page.items) ? page.items.map(normalize) : [];
168
+ // An explicit null means that the backend did not request or cannot provide
169
+ // a count. Preserve that evidence instead of turning it into a false exact
170
+ // count derived from the current page.
171
+ const totalCount = page.totalCount !== undefined
172
+ ? page.totalCount
173
+ : (page.total_count !== undefined ? page.total_count : items.length);
174
+ const countStatus = page.countStatus ?? page.count_status ?? 'exact';
175
+ const nextCursor = page.nextCursor ?? page.next_cursor ?? null;
176
+ const readStatus = page.readStatus ?? page.read_status ?? 'complete';
177
+ return {
178
+ ...page,
179
+ items,
180
+ totalCount,
181
+ countStatus,
182
+ nextCursor,
183
+ readStatus,
184
+ total_count: totalCount,
185
+ count_status: countStatus,
186
+ next_cursor: nextCursor,
187
+ read_status: readStatus,
188
+ warnings: items.flatMap((task) => Array.isArray(task.normalization_warnings) ? task.normalization_warnings.map(String) : []),
189
+ };
190
+ }
191
+ export class CanonicalTaskService {
192
+ repository;
193
+ auditRepository;
194
+ policy;
195
+ operationRepository;
196
+ clock;
197
+ baseUrl;
198
+ constructor(options) {
199
+ if (!options?.repository)
200
+ throw new Error('Canonical Task repository is required');
201
+ this.repository = options.repository;
202
+ this.auditRepository = options.auditRepository;
203
+ this.policy = options.policy ?? { authorize: () => undefined };
204
+ this.operationRepository = options.operationRepository;
205
+ this.clock = options.clock ?? (() => new Date());
206
+ this.baseUrl = options.baseUrl;
207
+ if (typeof options.baseUrl === 'string' && options.baseUrl && !validHttpUrl(options.baseUrl)) {
208
+ throw new Error('Canonical Task baseUrl must be an HTTP(S) URL');
209
+ }
210
+ }
211
+ async listTasks(filters = {}, context) {
212
+ const normalizedContext = await this.normalizeContext(context);
213
+ const normalizedFilters = this.normalizeListFilters(filters);
214
+ await this.authorize('list', normalizedContext, undefined, normalizedFilters);
215
+ const scoped = await this.scopeFilters('list', normalizedContext, normalizedFilters);
216
+ const page = await this.read(() => this.repository.list(scoped));
217
+ return {
218
+ ...normalizePage(page, (task) => this.normalizeResponse(task)),
219
+ as_of: this.clock().toISOString(),
220
+ };
221
+ }
222
+ async searchTasks(filters = {}, context) {
223
+ const normalizedContext = await this.normalizeContext(context);
224
+ const normalizedFilters = this.normalizeSearchFilters(filters);
225
+ await this.authorize('search', normalizedContext, undefined, normalizedFilters);
226
+ const scoped = await this.scopeFilters('search', normalizedContext, normalizedFilters);
227
+ if (typeof this.repository.search !== 'function') {
228
+ throw new CanonicalTaskError('task_search_unavailable', 'Canonical Task search is unavailable', 503);
229
+ }
230
+ const page = await this.read(() => this.repository.search(scoped));
231
+ const normalizedPage = normalizePage(page, (task) => this.normalizeResponse(task));
232
+ return {
233
+ ...normalizedPage,
234
+ query: normalizedFilters.q ?? '',
235
+ has_more: Boolean(page.hasMore ?? page.has_more),
236
+ as_of: this.clock().toISOString(),
237
+ };
238
+ }
239
+ async getTask(taskId, context) {
240
+ const normalizedId = normalizeString(taskId, 'task_id', { required: true, max: 200 });
241
+ const normalizedContext = await this.normalizeContext(context);
242
+ const task = await this.read(() => this.repository.get(normalizedId));
243
+ if (!task)
244
+ throw new CanonicalTaskError('task_not_found', 'Task was not found', 404, { task_id: normalizedId });
245
+ await this.authorize('read', normalizedContext, task);
246
+ return this.normalizeResponse(task);
247
+ }
248
+ async createTask(input, context) {
249
+ const normalizedContext = await this.normalizeContext(context);
250
+ const clientKey = this.requireIdempotencyKey(normalizedContext);
251
+ const payload = this.normalizeCreateInput(input);
252
+ await this.authorize('create', normalizedContext, undefined, payload);
253
+ const operation = operationKey(normalizedContext.principal, clientKey, 'api:');
254
+ const inputFingerprint = fingerprint(payload);
255
+ const recover = async () => {
256
+ const existing = await this.read(() => this.repository.findByIdempotencyKey(operation));
257
+ if (!existing)
258
+ return { recovered: false };
259
+ const existingFingerprint = extractPersistedFingerprint(existing);
260
+ if (existingFingerprint && existingFingerprint !== inputFingerprint) {
261
+ throw new CanonicalTaskError('idempotency_conflict', 'The idempotency key was already used for another task', 409, {
262
+ idempotency_key: clientKey,
263
+ });
264
+ }
265
+ // Recovery is a fresh authorization boundary. A persisted operation is
266
+ // never trusted merely because its idempotency key matches.
267
+ await this.authorize('read', normalizedContext, existing);
268
+ return { recovered: true, result: this.normalizeResponse(existing) };
269
+ };
270
+ const run = async () => {
271
+ const recovered = await recover();
272
+ if (recovered.recovered && recovered.result)
273
+ return recovered.result;
274
+ const created = await this.read(() => this.repository.create({
275
+ ...payload,
276
+ status: 'pending',
277
+ version: 1,
278
+ idempotency_key: operation,
279
+ payload_fingerprint: inputFingerprint,
280
+ }));
281
+ return this.normalizeResponse(created);
282
+ };
283
+ const result = await this.executeOperation({
284
+ scope: 'canonical_task',
285
+ operationKey: operation,
286
+ fingerprint: inputFingerprint,
287
+ projectResult: projectTaskResult,
288
+ recover,
289
+ run,
290
+ });
291
+ const normalized = this.normalizeResponse(result);
292
+ // Audit each invocation, including a replay/recovery. The deterministic
293
+ // entry id lets an upsert sink collapse retries without losing evidence
294
+ // that the audit write was attempted again.
295
+ await this.audit(normalizedContext, 'canonical_task.created', normalized, { before: null, after: normalized }, operation, inputFingerprint);
296
+ return normalized;
297
+ }
298
+ async updateTask(taskId, input, expectedVersionOrContext, maybeContext) {
299
+ const expectedVersion = typeof expectedVersionOrContext === 'number'
300
+ ? expectedVersionOrContext
301
+ : input.expected_version;
302
+ const context = typeof expectedVersionOrContext === 'number' ? maybeContext : expectedVersionOrContext;
303
+ const normalizedContext = await this.normalizeContext(context);
304
+ const patch = this.normalizeUpdateInput(input);
305
+ const expected = this.requireExpectedVersion(expectedVersion);
306
+ return this.versionedMutation({
307
+ taskId,
308
+ expectedVersion: expected,
309
+ context: normalizedContext,
310
+ action: 'update',
311
+ patch,
312
+ });
313
+ }
314
+ async transitionTask(taskId, statusOrInput, expectedVersionOrContext, maybeContext, maybeInput = {}) {
315
+ const legacyInput = statusOrInput && typeof statusOrInput === 'object' ? statusOrInput : null;
316
+ const status = legacyInput ? legacyInput.to_status : statusOrInput;
317
+ const expectedVersion = legacyInput ? legacyInput.expected_version : expectedVersionOrContext;
318
+ const context = legacyInput ? expectedVersionOrContext : maybeContext;
319
+ const input = legacyInput
320
+ ? { waitingOn: legacyInput.waiting_on, reviewAt: legacyInput.review_at }
321
+ : maybeInput;
322
+ const normalizedContext = await this.normalizeContext(context);
323
+ const expected = this.requireExpectedVersion(expectedVersion);
324
+ if (!isCanonicalTaskStatus(status))
325
+ failValidation({ to_status: ['invalid_status'] });
326
+ const patch = { status };
327
+ if (status === 'waiting') {
328
+ if (input.waitingOn == null || !String(input.waitingOn).normalize('NFKC').trim()) {
329
+ failValidation({ waiting_on: ['required_for_waiting'] });
330
+ }
331
+ patch.waiting_on = normalizeString(input.waitingOn, 'waiting_on', { required: true, max: 500 });
332
+ patch.review_at = normalizeIsoDate(input.reviewAt, 'review_at');
333
+ }
334
+ else {
335
+ patch.waiting_on = input.waitingOn === undefined ? null : normalizeString(input.waitingOn, 'waiting_on', { max: 500 });
336
+ patch.review_at = input.reviewAt === undefined ? null : normalizeIsoDate(input.reviewAt, 'review_at');
337
+ }
338
+ return this.versionedMutation({
339
+ taskId,
340
+ expectedVersion: expected,
341
+ context: normalizedContext,
342
+ action: 'transition',
343
+ patch,
344
+ transitionStatus: status,
345
+ });
346
+ }
347
+ async deleteTask(taskId, expectedVersionOrInput, context) {
348
+ const normalizedId = normalizeString(taskId, 'task_id', { required: true, max: 200 });
349
+ const normalizedContext = await this.normalizeContext(context);
350
+ const version = this.requireExpectedVersion(typeof expectedVersionOrInput === 'number'
351
+ ? expectedVersionOrInput
352
+ : expectedVersionOrInput.expected_version);
353
+ const clientKey = this.requireIdempotencyKey(normalizedContext);
354
+ // A prepared-delete coordinator owns the durable operation lookup. Do not
355
+ // require the row to remain visible before entering it: a completed
356
+ // operation may legitimately recover after the delete removed the row.
357
+ // The fallback path still performs the ordinary read and authorization
358
+ // checks before invoking the local operation runner.
359
+ let task = null;
360
+ if (!this.operationRepository?.executePreparedDelete) {
361
+ task = await this.read(() => this.repository.get(normalizedId));
362
+ if (!task)
363
+ throw new CanonicalTaskError('task_not_found', 'Task was not found', 404, { task_id: normalizedId });
364
+ await this.authorize('delete', normalizedContext, task);
365
+ this.assertVersion(task, version);
366
+ }
367
+ // The client key identifies the delete command. The task/version claim is
368
+ // carried separately so an operation coordinator can atomically claim the
369
+ // exact row version without allowing another actor to replay the command.
370
+ const operation = operationKey(normalizedContext.principal, clientKey, 'delete:');
371
+ const versionClaimKey = `task-version:${normalizedId}:${version}`;
372
+ const versionFingerprint = fingerprint({
373
+ kind: 'delete',
374
+ taskId: normalizedId,
375
+ expectedVersion: version,
376
+ });
377
+ const actorNamespace = principalNamespace(normalizedContext.principal);
378
+ const operationFingerprint = fingerprint({
379
+ kind: 'delete',
380
+ taskId: normalizedId,
381
+ expectedVersion: version,
382
+ principalNamespace: actorNamespace,
383
+ });
384
+ const result = {
385
+ task_id: normalizedId,
386
+ deleted: true,
387
+ version: version + 1,
388
+ };
389
+ const prepare = async () => {
390
+ const current = await this.read(() => this.repository.get(normalizedId));
391
+ if (!current)
392
+ throw new CanonicalTaskError('task_not_found', 'Task was not found', 404, { task_id: normalizedId });
393
+ await this.authorize('delete', normalizedContext, current);
394
+ this.assertVersion(current, version);
395
+ task = current;
396
+ return {
397
+ result: {
398
+ ...result,
399
+ _audit_source_refs: Array.isArray(current.source_refs) ? current.source_refs : [],
400
+ },
401
+ task: current,
402
+ authorizationSnapshot: {
403
+ task_id: normalizedId,
404
+ task_version: current.version,
405
+ actor: normalizedContext.principal,
406
+ auth_source: normalizedContext.authSource ?? null,
407
+ },
408
+ };
409
+ };
410
+ const findTask = async () => this.read(() => this.repository.get(normalizedId)).then((current) => {
411
+ if (current)
412
+ task = current;
413
+ return current;
414
+ });
415
+ const removeTask = async (current) => {
416
+ const taskToRemove = current ?? await this.read(() => this.repository.get(normalizedId));
417
+ if (!taskToRemove)
418
+ throw new CanonicalTaskError('task_not_found', 'Task was not found', 404, { task_id: normalizedId });
419
+ task = taskToRemove;
420
+ // The coordinator may call removeTask after an earlier prepare step.
421
+ // Recheck the current policy at the destructive boundary so a revoked
422
+ // actor cannot delete a task using a previously prepared authorization.
423
+ await this.authorize('delete', normalizedContext, taskToRemove);
424
+ this.assertVersion(taskToRemove, version);
425
+ await this.read(() => this.repository.delete(normalizedId, version));
426
+ };
427
+ let deleted;
428
+ if (this.operationRepository?.executePreparedDelete) {
429
+ deleted = await this.operationRepository.executePreparedDelete({
430
+ scope: 'canonical_task',
431
+ operationKey: operation,
432
+ fingerprint: operationFingerprint,
433
+ versionFingerprint,
434
+ versionClaimKey,
435
+ principalNamespace: actorNamespace,
436
+ prepare,
437
+ findTask,
438
+ removeTask,
439
+ projectResult: projectTaskResult,
440
+ });
441
+ }
442
+ else {
443
+ deleted = await this.executeOperation({
444
+ scope: 'canonical_task',
445
+ operationKey: operation,
446
+ fingerprint: operationFingerprint,
447
+ projectResult: projectTaskResult,
448
+ run: async () => {
449
+ const prepared = await prepare();
450
+ await removeTask(prepared.task);
451
+ return prepared.result;
452
+ },
453
+ });
454
+ }
455
+ // A completed replay may return without prepare/findTask, because the
456
+ // durable operation result is sufficient. Keep the audit target stable in
457
+ // that case while avoiding a second task-store read that would turn a
458
+ // successful replay into task_not_found.
459
+ const publicDeleted = {
460
+ task_id: deleted.task_id,
461
+ deleted: true,
462
+ version: deleted.version,
463
+ };
464
+ const auditSourceRefs = Array.isArray(task?.source_refs)
465
+ ? task.source_refs
466
+ : (Array.isArray(deleted._audit_source_refs) ? deleted._audit_source_refs : null);
467
+ // A legacy completed operation may only contain the public delete result.
468
+ // Do not synthesize source_refs: [] in that case, because an audit upsert
469
+ // would erase the source references recorded by the original execution.
470
+ if (!task && auditSourceRefs === null)
471
+ return publicDeleted;
472
+ const auditTask = task ?? {
473
+ id: normalizedId,
474
+ version,
475
+ title: '',
476
+ status: 'pending',
477
+ priority: 'medium',
478
+ source_refs: auditSourceRefs ?? [],
479
+ };
480
+ await this.audit(normalizedContext, 'canonical_task.deleted', auditTask, { before: { task_id: normalizedId, version }, after: null }, operation, operationFingerprint);
481
+ return publicDeleted;
482
+ }
483
+ async versionedMutation(input) {
484
+ const taskId = normalizeString(input.taskId, 'task_id', { required: true, max: 200 });
485
+ const expectedVersion = this.requireExpectedVersion(input.expectedVersion);
486
+ const current = await this.read(() => this.repository.get(taskId));
487
+ if (!current)
488
+ throw new CanonicalTaskError('task_not_found', 'Task was not found', 404, { task_id: taskId });
489
+ await this.authorize(input.action, input.context, current, input.patch);
490
+ const operation = operationKey(input.context.principal, `${taskId}:${expectedVersion}`, `${input.action}:`);
491
+ const mutationTimestamp = this.clock().toISOString();
492
+ const patch = {
493
+ ...input.patch,
494
+ version: expectedVersion + 1,
495
+ updated_at: mutationTimestamp,
496
+ ...(input.action === 'transition'
497
+ ? { completed_at: input.transitionStatus === 'completed' ? mutationTimestamp : null }
498
+ : {}),
499
+ };
500
+ // Keep this fingerprint independent of the wall clock. A restart may
501
+ // reconstruct the same command at a different instant, while the marker
502
+ // must still identify the already-applied mutation.
503
+ const operationFingerprint = fingerprint({
504
+ action: input.action,
505
+ taskId,
506
+ expectedVersion,
507
+ patch: input.patch,
508
+ transitionStatus: input.transitionStatus ?? null,
509
+ principal: input.context.principal,
510
+ });
511
+ const wasApplied = (candidate) => candidate.version === expectedVersion + 1
512
+ && candidate._last_operation_key === operation
513
+ && candidate._last_operation_fingerprint === operationFingerprint;
514
+ if (!wasApplied(current)) {
515
+ this.assertVersion(current, expectedVersion);
516
+ if (input.transitionStatus && !canTransitionCanonicalTaskStatus(current.status, input.transitionStatus)) {
517
+ throw new CanonicalTaskError('invalid_transition', `Cannot transition task from ${current.status} to ${input.transitionStatus}`, 409, {
518
+ from: current.status,
519
+ to: input.transitionStatus,
520
+ }, { currentTask: this.normalizeResponse(current) });
521
+ }
522
+ }
523
+ const recover = async () => {
524
+ const latest = await this.read(() => this.repository.get(taskId));
525
+ if (!latest)
526
+ throw new CanonicalTaskError('task_not_found', 'Task was not found', 404, { task_id: taskId });
527
+ await this.authorize(input.action, input.context, latest, input.patch);
528
+ if (wasApplied(latest))
529
+ return { recovered: true, result: this.normalizeResponse(latest) };
530
+ this.assertVersion(latest, expectedVersion);
531
+ if (input.transitionStatus && !canTransitionCanonicalTaskStatus(latest.status, input.transitionStatus)) {
532
+ throw new CanonicalTaskError('invalid_transition', `Cannot transition task from ${latest.status} to ${input.transitionStatus}`, 409, {
533
+ from: latest.status,
534
+ to: input.transitionStatus,
535
+ }, { currentTask: this.normalizeResponse(latest) });
536
+ }
537
+ return { recovered: false };
538
+ };
539
+ const run = async () => {
540
+ const latest = await this.read(() => this.repository.get(taskId));
541
+ if (!latest)
542
+ throw new CanonicalTaskError('task_not_found', 'Task was not found', 404, { task_id: taskId });
543
+ if (wasApplied(latest))
544
+ return this.normalizeResponse(latest);
545
+ this.assertVersion(latest, expectedVersion);
546
+ if (input.transitionStatus && !canTransitionCanonicalTaskStatus(latest.status, input.transitionStatus)) {
547
+ throw new CanonicalTaskError('invalid_transition', `Cannot transition task from ${latest.status} to ${input.transitionStatus}`, 409, {
548
+ from: latest.status,
549
+ to: input.transitionStatus,
550
+ }, { currentTask: this.normalizeResponse(latest) });
551
+ }
552
+ const updated = await this.read(() => this.repository.update(taskId, {
553
+ ...patch,
554
+ _last_operation_key: operation,
555
+ _last_operation_fingerprint: operationFingerprint,
556
+ }));
557
+ return this.normalizeResponse(updated);
558
+ };
559
+ const result = await this.executeOperation({
560
+ scope: 'canonical_task',
561
+ operationKey: operation,
562
+ fingerprint: operationFingerprint,
563
+ projectResult: projectTaskResult,
564
+ recover,
565
+ run,
566
+ });
567
+ const normalized = this.normalizeResponse(result);
568
+ const auditAction = input.action === 'update'
569
+ ? 'canonical_task.updated'
570
+ : 'canonical_task.transitioned';
571
+ const changes = input.action === 'update'
572
+ ? {
573
+ before: { version: expectedVersion },
574
+ after: { version: normalized.version },
575
+ fields: input.patch,
576
+ }
577
+ : {
578
+ before: { version: expectedVersion },
579
+ after: { status: normalized.status, version: normalized.version },
580
+ transition: {
581
+ to_status: input.transitionStatus,
582
+ ...(input.patch.waiting_on !== undefined ? { waiting_on: input.patch.waiting_on } : {}),
583
+ ...(input.patch.review_at !== undefined ? { review_at: input.patch.review_at } : {}),
584
+ },
585
+ };
586
+ await this.audit(input.context, auditAction, normalized, changes, operation, operationFingerprint);
587
+ return normalized;
588
+ }
589
+ normalizeCreateInput(input) {
590
+ if (!input || typeof input !== 'object')
591
+ fail('validation_error', 'Task input is required');
592
+ const unknownFields = Object.keys(input).filter((field) => !CREATE_FIELDS.has(field));
593
+ if (unknownFields.length > 0)
594
+ fail('validation_error', 'Task input contains unsupported fields', 400, { fields: unknownFields });
595
+ const title = normalizeString(input.title, 'title', { required: true, max: MAX_TITLE_LENGTH });
596
+ const priority = input.priority == null || input.priority === '' ? 'medium' : String(input.priority);
597
+ if (!isCanonicalTaskPriority(priority))
598
+ fail('validation_error', 'priority is invalid', 400, { field: 'priority' });
599
+ return {
600
+ title,
601
+ description: normalizeString(input.description, 'description', { max: 10000 }),
602
+ priority,
603
+ assignee_person_id: normalizeString(input.assignee_person_id, 'assignee_person_id', { max: 200 }),
604
+ due_at: normalizeIsoDate(input.due_at, 'due_at'),
605
+ waiting_on: normalizeString(input.waiting_on, 'waiting_on', { max: 500 }),
606
+ review_at: normalizeIsoDate(input.review_at, 'review_at'),
607
+ source_refs: normalizeSourceRefs(input.source_refs) ?? [],
608
+ project_codes: normalizeProjectCodes(input.project_codes) ?? [],
609
+ };
610
+ }
611
+ normalizeUpdateInput(input) {
612
+ if (!input || typeof input !== 'object')
613
+ fail('validation_error', 'Task update is required');
614
+ const unknownFields = Object.keys(input).filter((field) => !MUTABLE_FIELDS.has(field));
615
+ if (unknownFields.length > 0)
616
+ fail('validation_error', 'Task update contains unsupported fields', 400, { fields: unknownFields });
617
+ const patch = {};
618
+ if ('title' in input)
619
+ patch.title = normalizeString(input.title, 'title', { required: true, max: MAX_TITLE_LENGTH });
620
+ if ('description' in input)
621
+ patch.description = normalizeString(input.description, 'description', { max: 10000 });
622
+ if ('priority' in input) {
623
+ const priority = input.priority == null || input.priority === '' ? null : String(input.priority);
624
+ if (priority != null && !isCanonicalTaskPriority(priority))
625
+ fail('validation_error', 'priority is invalid', 400, { field: 'priority' });
626
+ patch.priority = priority;
627
+ }
628
+ if ('assignee_person_id' in input)
629
+ patch.assignee_person_id = normalizeString(input.assignee_person_id, 'assignee_person_id', { max: 200 });
630
+ if ('due_at' in input)
631
+ patch.due_at = normalizeIsoDate(input.due_at, 'due_at');
632
+ if ('waiting_on' in input)
633
+ patch.waiting_on = normalizeString(input.waiting_on, 'waiting_on', { max: 500 });
634
+ if ('review_at' in input)
635
+ patch.review_at = normalizeIsoDate(input.review_at, 'review_at');
636
+ if ('source_refs' in input)
637
+ patch.source_refs = normalizeSourceRefs(input.source_refs) ?? [];
638
+ if ('project_codes' in input)
639
+ patch.project_codes = normalizeProjectCodes(input.project_codes) ?? [];
640
+ if (Object.keys(patch).length === 0)
641
+ fail('validation_error', 'Task update must include at least one mutable field');
642
+ return patch;
643
+ }
644
+ normalizeListFilters(filters) {
645
+ const { status: _status, priority: _priority, projectCode: _projectCode, project_code: _project_code, ...canonicalFilters } = filters;
646
+ const statuses = normalizeCollection(filters.statuses ?? filters.status, 'statuses');
647
+ const priorities = normalizeCollection(filters.priorities ?? filters.priority, 'priorities');
648
+ const projectCodes = normalizeProjectCodes(filters.projectCodes ?? filters.projectCode ?? filters.project_code);
649
+ return {
650
+ ...canonicalFilters,
651
+ statuses,
652
+ priorities,
653
+ projectCodes,
654
+ assigneePersonId: normalizeString(filters.assigneePersonId, 'assigneePersonId', { max: 200 }),
655
+ dueAfter: normalizeIsoDate(filters.dueAfter, 'dueAfter'),
656
+ dueBefore: normalizeIsoDate(filters.dueBefore, 'dueBefore'),
657
+ limit: normalizeLimit(filters.limit),
658
+ cursor: normalizeCursor(filters.cursor),
659
+ };
660
+ }
661
+ normalizeSearchFilters(filters) {
662
+ const normalized = this.normalizeListFilters(filters);
663
+ const rawQuery = filters.q ?? filters.query;
664
+ const query = typeof rawQuery === 'string' ? rawQuery.normalize('NFKC').trim() : '';
665
+ if (!query)
666
+ failValidation({ query: ['required'] });
667
+ if (/[\u0000-\u001f\u007f]/u.test(query))
668
+ failValidation({ query: ['contains_control_characters'] });
669
+ if (query.length > MAX_SEARCH_LENGTH)
670
+ failValidation({ query: ['too_long'] });
671
+ const q = normalizeString(query, 'query', { required: true, max: MAX_SEARCH_LENGTH });
672
+ const tokens = q.split(/\s+/u).filter(Boolean);
673
+ const limit = normalizeLimit(filters.limit, 20);
674
+ return { ...normalized, q, query: q, tokens, limit };
675
+ }
676
+ async normalizeContext(context) {
677
+ if (!context || typeof context !== 'object') {
678
+ throw new CanonicalTaskError('unauthenticated_context', 'A task context is required', 401);
679
+ }
680
+ let normalized;
681
+ try {
682
+ normalized = {
683
+ ...context,
684
+ principal: normalizeCanonicalTaskPrincipal(context.principal),
685
+ };
686
+ }
687
+ catch (error) {
688
+ throw new CanonicalTaskError('unauthenticated_context', error instanceof Error ? error.message : 'Principal is invalid', 401);
689
+ }
690
+ const fromPolicy = this.policy.normalizeContext
691
+ ? await this.policy.normalizeContext(normalized)
692
+ : normalized;
693
+ try {
694
+ return { ...fromPolicy, principal: normalizeCanonicalTaskPrincipal(fromPolicy.principal) };
695
+ }
696
+ catch (error) {
697
+ throw new CanonicalTaskError('unauthenticated_context', error instanceof Error ? error.message : 'Principal is invalid', 401);
698
+ }
699
+ }
700
+ async authorize(action, context, task, input) {
701
+ await this.policy.authorize({ action, context, task, input });
702
+ }
703
+ async scopeFilters(operation, context, filters) {
704
+ if (!this.policy.scopeFilters)
705
+ return filters;
706
+ const scoped = await this.policy.scopeFilters({ operation, context, filters });
707
+ return { ...filters, ...(scoped ?? {}) };
708
+ }
709
+ requireIdempotencyKey(context) {
710
+ const key = normalizeString(context.idempotencyKey, 'idempotency_key', { required: true, max: 200 });
711
+ if (RESERVED_IDEMPOTENCY_PREFIXES.some((prefix) => key.startsWith(prefix))) {
712
+ fail('validation_error', 'idempotency_key uses a reserved prefix', 400, { field: 'idempotency_key' });
713
+ }
714
+ return key;
715
+ }
716
+ requireExpectedVersion(value) {
717
+ const version = Number(value);
718
+ if (!Number.isInteger(version) || version < 1)
719
+ fail('validation_error', 'expected_version must be a positive integer', 400, { field: 'expected_version' });
720
+ return version;
721
+ }
722
+ assertVersion(task, expectedVersion) {
723
+ if (task.version !== expectedVersion) {
724
+ throw new CanonicalTaskError('version_conflict', 'Task version is stale', 409, {
725
+ expected_version: expectedVersion,
726
+ actual_version: task.version,
727
+ }, { currentTask: this.normalizeResponse(task) });
728
+ }
729
+ }
730
+ async executeOperation(request) {
731
+ return this.operationRepository
732
+ ? this.operationRepository.execute(request)
733
+ : request.run();
734
+ }
735
+ async audit(context, action, task, changes, operationKey, operationFingerprint) {
736
+ if (!this.auditRepository)
737
+ return;
738
+ const actor = context.auditPrincipal
739
+ ? normalizeCanonicalTaskPrincipal(context.auditPrincipal)
740
+ : context.principal;
741
+ const entry = {
742
+ // The operation identity, rather than the returned task payload, is the
743
+ // replay key. A retried command therefore upserts the same audit row
744
+ // even when a repository adds timestamps or other response fields.
745
+ id: `canonical-task:${fingerprint({ action, operationKey, operationFingerprint })}`,
746
+ actor,
747
+ actor_id: actor.id,
748
+ actor_type: actor.type,
749
+ actor_principal: actor,
750
+ actor_namespace: principalNamespace(actor),
751
+ auth_source: context.auditAuthSource ?? context.authSource ?? null,
752
+ action,
753
+ target_type: 'canonical_task',
754
+ target_id: task.id,
755
+ changes,
756
+ source_refs: Array.isArray(task.source_refs) ? task.source_refs : [],
757
+ };
758
+ try {
759
+ await this.auditRepository.upsertAuditLog(entry);
760
+ }
761
+ catch {
762
+ throw new CanonicalTaskError('task_audit_unavailable', 'Task audit is unavailable', 503);
763
+ }
764
+ }
765
+ async read(operation) {
766
+ try {
767
+ return await operation();
768
+ }
769
+ catch (error) {
770
+ if (error instanceof CanonicalTaskError)
771
+ throw error;
772
+ // Keep the fail-closed backend selection contract observable to the
773
+ // host boundary while still hiding arbitrary storage details.
774
+ if (error && typeof error === 'object'
775
+ && error.code === 'canonical_task_backend_not_configured') {
776
+ throw new CanonicalTaskError('canonical_task_backend_not_configured', 'Canonical Task backend is not configured', 503);
777
+ }
778
+ throw new CanonicalTaskError('task_store_unavailable', 'Task store is unavailable', 503);
779
+ }
780
+ }
781
+ normalizeResponse(task) {
782
+ const response = { ...task };
783
+ delete response._payload_fingerprint;
784
+ delete response._last_operation_key;
785
+ delete response._last_operation_fingerprint;
786
+ const baseUrl = typeof this.baseUrl === 'function' ? this.baseUrl() : this.baseUrl;
787
+ const existingUrl = validHttpUrl(response.web_url);
788
+ const fallbackBaseUrl = validHttpUrl(baseUrl);
789
+ response.web_url = existingUrl
790
+ ?? (fallbackBaseUrl ? `${fallbackBaseUrl.replace(/\/$/u, '')}/api/tasks/${encodeURIComponent(task.id)}` : null);
791
+ if (response.description === undefined)
792
+ response.description = null;
793
+ if (response.assignee_person_id === undefined)
794
+ response.assignee_person_id = null;
795
+ if (response.due_at === undefined)
796
+ response.due_at = null;
797
+ if (response.waiting_on === undefined)
798
+ response.waiting_on = null;
799
+ if (response.review_at === undefined)
800
+ response.review_at = null;
801
+ if (response.completed_at === undefined)
802
+ response.completed_at = null;
803
+ if (response.source_refs === undefined)
804
+ response.source_refs = [];
805
+ if (response.project_codes === undefined)
806
+ response.project_codes = [];
807
+ return response;
808
+ }
809
+ }