@unson/brainbase-mcp 0.44.0 → 0.44.1

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 (72) hide show
  1. package/contracts/canonical-task/operation-schema.sql +58 -0
  2. package/contracts/canonical-task/search-indexes.sql +10 -0
  3. package/contracts/canonical-task/store-schema.sql +77 -0
  4. package/dist/automation-run.d.ts +213 -0
  5. package/dist/automation-run.js +479 -0
  6. package/dist/canonical-task-operations.d.ts +205 -0
  7. package/dist/canonical-task-operations.js +700 -0
  8. package/dist/canonical-task-postgres.d.ts +139 -0
  9. package/dist/canonical-task-postgres.js +554 -0
  10. package/dist/canonical-task-service.d.ts +13 -0
  11. package/dist/canonical-task-service.js +55 -3
  12. package/dist/canonical-task-store-config.d.ts +31 -0
  13. package/dist/canonical-task-store-config.js +53 -0
  14. package/dist/config-parser.d.ts +298 -0
  15. package/dist/config-parser.js +593 -0
  16. package/dist/connected-onboarding.d.ts +57 -0
  17. package/dist/connected-onboarding.js +37 -0
  18. package/dist/durable-wait-http.d.ts +3 -1
  19. package/dist/durable-wait-http.js +73 -6
  20. package/dist/durable-waits.d.ts +93 -3
  21. package/dist/durable-waits.js +320 -8
  22. package/dist/honcho.d.ts +25 -0
  23. package/dist/honcho.js +81 -0
  24. package/dist/judgment-event-adapter.d.ts +72 -0
  25. package/dist/judgment-event-adapter.js +114 -0
  26. package/dist/judgment-event-outbox.d.ts +182 -0
  27. package/dist/judgment-event-outbox.js +568 -0
  28. package/dist/judgment-execution-outcome.d.ts +46 -0
  29. package/dist/judgment-execution-outcome.js +149 -0
  30. package/dist/judgment-host.d.ts +18 -7
  31. package/dist/judgment-host.js +82 -34
  32. package/dist/judgment-lifecycle-core.d.ts +198 -0
  33. package/dist/judgment-lifecycle-core.js +464 -0
  34. package/dist/judgment-node-evidence.d.ts +57 -0
  35. package/dist/judgment-node-evidence.js +297 -0
  36. package/dist/judgment-runtime-resolution.d.ts +264 -0
  37. package/dist/judgment-runtime-resolution.js +1248 -0
  38. package/dist/judgment-runtime-resolution.public.d.ts +255 -0
  39. package/dist/knowledge-event.d.ts +28 -0
  40. package/dist/knowledge-event.js +114 -0
  41. package/dist/mcp-runtime.d.ts +75 -0
  42. package/dist/mcp-runtime.js +188 -0
  43. package/dist/meeting-automation-runtime.d.ts +102 -0
  44. package/dist/meeting-automation-runtime.js +249 -0
  45. package/dist/meeting-automation.d.ts +155 -0
  46. package/dist/meeting-automation.js +341 -0
  47. package/dist/meeting-minutes-context-receipt.d.ts +91 -0
  48. package/dist/meeting-minutes-context-receipt.js +280 -0
  49. package/dist/meeting-source-sync.d.ts +89 -0
  50. package/dist/meeting-source-sync.js +230 -0
  51. package/dist/mesh.d.ts +58 -0
  52. package/dist/mesh.js +134 -0
  53. package/dist/onboarding-runtime.d.ts +47 -0
  54. package/dist/onboarding-runtime.js +4719 -0
  55. package/dist/routine-cycle.d.ts +51 -0
  56. package/dist/routine-cycle.js +153 -0
  57. package/dist/routine-liveness.d.ts +49 -0
  58. package/dist/routine-liveness.js +227 -0
  59. package/dist/routine-runtime.d.ts +101 -0
  60. package/dist/routine-runtime.js +237 -0
  61. package/dist/routines.d.ts +3 -0
  62. package/dist/routines.js +3 -0
  63. package/dist/run-receipt.d.ts +40 -0
  64. package/dist/run-receipt.js +635 -0
  65. package/dist/runtime-host.d.ts +64 -0
  66. package/dist/runtime-host.js +70 -0
  67. package/dist/world-work.d.ts +16 -0
  68. package/package.json +115 -3
  69. package/ui/objective-editor.js +47 -11
  70. package/ui/world/world-district.js +18 -3
  71. package/ui/world/world-placement.js +13 -0
  72. package/ui/world/world-work-rail.js +7 -1
@@ -0,0 +1,554 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ const STATUSES = new Set(['pending', 'in_progress', 'waiting', 'completed', 'cancelled']);
3
+ const PRIORITIES = new Set(['low', 'medium', 'high', 'urgent']);
4
+ const SEARCH_CURSOR_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5
+ const MUTABLE_COLUMNS = Object.freeze({
6
+ title: 'title',
7
+ description: 'description',
8
+ status: 'status',
9
+ priority: 'priority',
10
+ assignee_person_id: 'assignee_person_id',
11
+ assignee_display_name: 'assignee_display_name',
12
+ due_at: 'due_at',
13
+ waiting_on: 'waiting_on',
14
+ waiting_on_person_id: 'waiting_on_person_id',
15
+ review_at: 'review_at',
16
+ completed_at: 'completed_at',
17
+ source_refs: 'source_refs',
18
+ project_codes: 'project_codes',
19
+ purpose_label: 'purpose_label',
20
+ completion_contract: 'completion_contract',
21
+ version: 'version',
22
+ idempotency_key: 'idempotency_key',
23
+ payload_fingerprint: 'payload_fingerprint',
24
+ last_operation_key: 'last_operation_key',
25
+ last_operation_fingerprint: 'last_operation_fingerprint',
26
+ });
27
+ /** Errors that keep their meaning across the store boundary. */
28
+ const PASSTHROUGH_ERROR_CODES = new Set([
29
+ 'task_not_found',
30
+ 'validation_failed',
31
+ 'version_conflict',
32
+ ]);
33
+ function storeError(code, message, status, extra = {}) {
34
+ const error = new Error(message);
35
+ Object.assign(error, { code, status, ...extra });
36
+ return error;
37
+ }
38
+ export function encodeCanonicalTaskCursor(offset) {
39
+ return Buffer.from(JSON.stringify({ v: 1, offset }), 'utf8').toString('base64url');
40
+ }
41
+ export function decodeCanonicalTaskCursor(cursor) {
42
+ if (!cursor)
43
+ return 0;
44
+ try {
45
+ const value = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
46
+ if (value?.v !== 1 || !Number.isInteger(value.offset) || value.offset < 0)
47
+ throw new Error();
48
+ return value.offset;
49
+ }
50
+ catch {
51
+ throw storeError('validation_failed', 'Invalid cursor', 422, { fieldErrors: { cursor: ['invalid_cursor'] } });
52
+ }
53
+ }
54
+ function isoOrNull(value) {
55
+ if (!value)
56
+ return null;
57
+ const date = new Date(value);
58
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
59
+ }
60
+ function encodeSearchCursor(row) {
61
+ return Buffer.from(JSON.stringify({ v: 1, created_at: isoOrNull(row.created_at), id: String(row.id) }), 'utf8')
62
+ .toString('base64url');
63
+ }
64
+ function decodeSearchCursor(cursor) {
65
+ if (!cursor)
66
+ return null;
67
+ try {
68
+ const decoded = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
69
+ const createdAt = isoOrNull(decoded.created_at);
70
+ if (decoded.v !== 1 || !createdAt || !SEARCH_CURSOR_ID.test(String(decoded.id || '')))
71
+ throw new Error();
72
+ return { createdAt, id: String(decoded.id) };
73
+ }
74
+ catch {
75
+ throw storeError('validation_failed', 'Canonical Task search cursor is invalid', 422, {
76
+ fieldErrors: { cursor: ['invalid_cursor'] },
77
+ });
78
+ }
79
+ }
80
+ function escapeLikeToken(value) {
81
+ return String(value).replace(/[\\%_]/g, '\\$&');
82
+ }
83
+ function normalizeSourceReferences(value) {
84
+ if (Array.isArray(value))
85
+ return { references: value, warnings: [] };
86
+ if (value == null)
87
+ return { references: [], warnings: [] };
88
+ try {
89
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
90
+ if (Array.isArray(parsed))
91
+ return { references: parsed, warnings: [] };
92
+ }
93
+ catch {
94
+ // Report invalid persisted data to the caller instead of hiding it.
95
+ }
96
+ return {
97
+ references: [],
98
+ warnings: [{ code: 'invalid_source_refs_shape', message: 'Task source references must be an array' }],
99
+ };
100
+ }
101
+ function notFound() {
102
+ return storeError('task_not_found', 'Task not found', 404);
103
+ }
104
+ function versionConflict(currentTask) {
105
+ return storeError('version_conflict', 'Task version is stale', 409, { currentTask });
106
+ }
107
+ function requireString(value, name) {
108
+ if (typeof value !== 'string' || !value.trim())
109
+ throw new Error(`Canonical Task PostgreSQL ${name} is required`);
110
+ return value;
111
+ }
112
+ /** The disabled backend: every operation reports that no store is configured. */
113
+ export class CanonicalTaskUnavailableRepository {
114
+ unavailable() {
115
+ throw storeError('canonical_task_backend_not_configured', 'CANONICAL_TASK_BACKEND is not configured', 503);
116
+ }
117
+ assertAvailable() { return this.unavailable(); }
118
+ list() { return this.unavailable(); }
119
+ search() { return this.unavailable(); }
120
+ get() { return this.unavailable(); }
121
+ findByIdempotencyKey() { return this.unavailable(); }
122
+ create() { return this.unavailable(); }
123
+ update() { return this.unavailable(); }
124
+ delete() { return this.unavailable(); }
125
+ listPurposeLabels() { return this.unavailable(); }
126
+ }
127
+ /**
128
+ * PostgreSQL persistence for Canonical Tasks (`canonical_tasks`, see
129
+ * `contracts/canonical-task/store-schema.sql`).
130
+ *
131
+ * Task IDs are opaque HMAC-signed `ct1.` tokens. The adapter does not decide
132
+ * who may see a Task: list/search visibility comes from the host's
133
+ * `scopeCondition`, and authorization stays in the service policy.
134
+ */
135
+ export class CanonicalTaskPostgresRepository {
136
+ /**
137
+ * PostgreSQL is a durable mutation boundary. Services must bind it to the
138
+ * persisted release readiness gate before exposing mutations.
139
+ */
140
+ requiresReadiness = true;
141
+ pool;
142
+ schemaVersion;
143
+ legacyIdentity;
144
+ webBaseUrl;
145
+ idSecret;
146
+ scopeCondition;
147
+ mutationGuard;
148
+ passthroughErrorCodes;
149
+ constructor(options) {
150
+ if (!options?.pool)
151
+ throw new Error('Canonical Task PostgreSQL pool is required');
152
+ this.pool = options.pool;
153
+ this.schemaVersion = requireString(options.schemaVersion, 'schema version');
154
+ this.idSecret = requireString(options.idSecret, 'opaque ID secret');
155
+ const legacy = options.legacyIdentity ?? null;
156
+ if (legacy) {
157
+ requireString(legacy.baseId, 'legacy base ID');
158
+ requireString(legacy.tableId, 'legacy table ID');
159
+ }
160
+ this.legacyIdentity = legacy ? Object.freeze({ baseId: legacy.baseId, tableId: legacy.tableId }) : null;
161
+ this.webBaseUrl = options.webBaseUrl ?? null;
162
+ this.scopeCondition = options.scopeCondition ?? null;
163
+ this.mutationGuard = options.mutationGuard ?? null;
164
+ this.passthroughErrorCodes = new Set([...PASSTHROUGH_ERROR_CODES, ...(options.passthroughErrorCodes ?? [])]);
165
+ }
166
+ sign(payload) {
167
+ return createHmac('sha256', this.idSecret).update(payload).digest('base64url');
168
+ }
169
+ encodePayload(value) {
170
+ const payload = Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
171
+ return `ct1.${payload}.${this.sign(payload)}`;
172
+ }
173
+ /** The public ID of a stored row. */
174
+ encodeTaskId(row) {
175
+ if (row.legacy_nocodb_id && this.legacyIdentity) {
176
+ return this.encodePayload({
177
+ v: this.schemaVersion,
178
+ b: this.legacyIdentity.baseId,
179
+ t: this.legacyIdentity.tableId,
180
+ r: String(row.legacy_nocodb_id),
181
+ });
182
+ }
183
+ return this.encodePayload({ v: this.schemaVersion, s: 'postgres', r: String(row.id) });
184
+ }
185
+ /** The stored row a public ID names. A tampered or foreign ID is task_not_found. */
186
+ decodeTaskLocator(taskId) {
187
+ try {
188
+ const [prefix, payload, signature] = String(taskId).split('.');
189
+ if (prefix !== 'ct1' || !payload || !signature)
190
+ throw new Error();
191
+ const actual = Buffer.from(signature);
192
+ const expected = Buffer.from(this.sign(payload));
193
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
194
+ throw new Error();
195
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
196
+ if (decoded.v !== this.schemaVersion || !decoded.r)
197
+ throw new Error();
198
+ if (decoded.s === 'postgres')
199
+ return { column: 'id', value: String(decoded.r) };
200
+ if (this.legacyIdentity && decoded.b === this.legacyIdentity.baseId && decoded.t === this.legacyIdentity.tableId) {
201
+ return { column: 'legacy_nocodb_id', value: String(decoded.r) };
202
+ }
203
+ throw new Error();
204
+ }
205
+ catch {
206
+ throw notFound();
207
+ }
208
+ }
209
+ normalize(row) {
210
+ if (!row)
211
+ return null;
212
+ const warnings = [];
213
+ let status = row.status;
214
+ let priority = row.priority;
215
+ if (!STATUSES.has(status)) {
216
+ warnings.push({ code: 'unknown_status', message: `Unknown Task status: ${status || ''}` });
217
+ status = String(status || 'unknown');
218
+ }
219
+ if (!PRIORITIES.has(priority)) {
220
+ warnings.push({ code: 'unknown_priority', message: `Unknown Task priority: ${priority || ''}` });
221
+ priority = String(priority || 'unknown');
222
+ }
223
+ if (!row.assignee_person_id && row.assignee_display_name) {
224
+ warnings.push({ code: 'assignee_unresolved', message: 'Legacy assignee has no Graph person ID' });
225
+ }
226
+ const sourceReferences = normalizeSourceReferences(row.source_refs);
227
+ warnings.push(...sourceReferences.warnings);
228
+ const id = this.encodeTaskId(row);
229
+ const task = {
230
+ id,
231
+ version: Number(row.version ?? 1),
232
+ title: String(row.title ?? ''),
233
+ description: row.description ?? null,
234
+ status,
235
+ priority,
236
+ project_codes: Array.isArray(row.project_codes) ? row.project_codes : [],
237
+ purpose_label: row.purpose_label ?? null,
238
+ completion_contract: row.completion_contract ?? null,
239
+ assignee_person_id: row.assignee_person_id ?? null,
240
+ assignee_display_name: row.assignee_display_name ?? null,
241
+ due_at: isoOrNull(row.due_at),
242
+ waiting_on: row.waiting_on ?? null,
243
+ waiting_on_person_id: row.waiting_on_person_id ?? null,
244
+ review_at: isoOrNull(row.review_at),
245
+ completed_at: isoOrNull(row.completed_at),
246
+ source_refs: sourceReferences.references,
247
+ created_at: isoOrNull(row.created_at) || new Date(0).toISOString(),
248
+ updated_at: isoOrNull(row.updated_at) || new Date(0).toISOString(),
249
+ web_url: this.webBaseUrl
250
+ ? new URL(`/api/companion/tasks/${encodeURIComponent(id)}`, this.webBaseUrl).toString()
251
+ : null,
252
+ normalization_warnings: warnings,
253
+ };
254
+ Object.defineProperties(task, {
255
+ _payload_fingerprint: { value: row.payload_fingerprint ?? null, enumerable: false },
256
+ _last_operation_key: { value: row.last_operation_key ?? null, enumerable: false },
257
+ _last_operation_fingerprint: { value: row.last_operation_fingerprint ?? null, enumerable: false },
258
+ });
259
+ return task;
260
+ }
261
+ /** Store failures become task_store_unavailable; meaningful errors pass through. */
262
+ unavailable(error) {
263
+ if (this.passthroughErrorCodes.has(error?.code))
264
+ return error;
265
+ return storeError('task_store_unavailable', 'PostgreSQL Task store is unavailable', 503, { cause: error });
266
+ }
267
+ async query(text, values = []) {
268
+ try {
269
+ return await this.pool.query(text, values);
270
+ }
271
+ catch (error) {
272
+ throw this.unavailable(error);
273
+ }
274
+ }
275
+ async withTransaction(callback) {
276
+ if (typeof this.pool.connect !== 'function') {
277
+ throw this.unavailable(new Error('PostgreSQL transaction client is unavailable'));
278
+ }
279
+ let client;
280
+ try {
281
+ client = await this.pool.connect();
282
+ await client.query('BEGIN');
283
+ const result = await callback(client);
284
+ await client.query('COMMIT');
285
+ return result;
286
+ }
287
+ catch (error) {
288
+ try {
289
+ await client?.query('ROLLBACK');
290
+ }
291
+ catch { /* preserve transaction error */ }
292
+ throw this.unavailable(error);
293
+ }
294
+ finally {
295
+ client?.release?.();
296
+ }
297
+ }
298
+ rejectStorageScope(storageScope) {
299
+ // This adapter keeps one table per store. Accepting a policy storage
300
+ // scope here would silently mix scopes, so refuse it.
301
+ if (storageScope !== undefined) {
302
+ throw storeError('task_storage_scope_unsupported', 'The PostgreSQL Task store does not partition by storage scope', 503);
303
+ }
304
+ }
305
+ visibility(filters, param) {
306
+ if (!this.scopeCondition)
307
+ return null;
308
+ const condition = this.scopeCondition({ filters, param });
309
+ return typeof condition === 'string' && condition.trim() ? `(${condition})` : null;
310
+ }
311
+ async list(filters = {}, storageScope) {
312
+ this.rejectStorageScope(storageScope);
313
+ const statuses = filters.statuses ?? [];
314
+ const priorities = filters.priorities ?? [];
315
+ const projectCodes = filters.projectCodes ?? [];
316
+ const { assigneePersonId, dueAfter, dueBefore, cursor } = filters;
317
+ const limit = Number(filters.limit ?? 50);
318
+ const offset = decodeCanonicalTaskCursor(cursor);
319
+ const conditions = [];
320
+ const values = [];
321
+ const param = (value) => { values.push(value); return `$${values.length}`; };
322
+ const add = (sql, value) => conditions.push(sql.replace('?', param(value)));
323
+ if (statuses.length)
324
+ add('status = ANY(?::text[])', statuses);
325
+ if (priorities.length)
326
+ add('priority = ANY(?::text[])', priorities);
327
+ if (projectCodes.length)
328
+ add('project_codes && ?::text[]', projectCodes);
329
+ if (assigneePersonId != null && assigneePersonId !== '')
330
+ add('assignee_person_id = ?', assigneePersonId);
331
+ if (dueAfter)
332
+ add('due_at >= ?::timestamptz', dueAfter);
333
+ if (dueBefore)
334
+ add('due_at <= ?::timestamptz', dueBefore);
335
+ const visible = this.visibility(filters, param);
336
+ if (visible)
337
+ conditions.push(visible);
338
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
339
+ const countResult = await this.query(`SELECT COUNT(*)::integer AS count FROM canonical_tasks ${where}`, values);
340
+ const rows = await this.query(`SELECT * FROM canonical_tasks ${where}
341
+ ORDER BY created_at ASC, id ASC
342
+ LIMIT $${values.length + 1} OFFSET $${values.length + 2}`, [...values, limit, offset]);
343
+ const totalCount = Number(countResult.rows[0]?.count || 0);
344
+ return {
345
+ items: rows.rows.map((row) => this.normalize(row)),
346
+ totalCount,
347
+ countStatus: 'exact',
348
+ readStatus: 'complete',
349
+ nextCursor: offset + rows.rows.length < totalCount ? encodeCanonicalTaskCursor(offset + rows.rows.length) : null,
350
+ };
351
+ }
352
+ async search(filters = {}, storageScope) {
353
+ this.rejectStorageScope(storageScope);
354
+ const tokens = filters.tokens ?? [];
355
+ const statuses = filters.statuses ?? [];
356
+ const priorities = filters.priorities ?? [];
357
+ const projectCodes = filters.projectCodes ?? [];
358
+ const { assigneePersonId } = filters;
359
+ const limit = Number(filters.limit ?? 20);
360
+ const after = decodeSearchCursor(filters.cursor);
361
+ const conditions = [];
362
+ const values = [];
363
+ const param = (value) => { values.push(value); return `$${values.length}`; };
364
+ const add = (sql, value) => conditions.push(sql.replace('?', param(value)));
365
+ for (const token of tokens)
366
+ add("title ILIKE ? ESCAPE E'\\\\'", `%${escapeLikeToken(token)}%`);
367
+ if (statuses.length)
368
+ add('status = ANY(?::text[])', statuses);
369
+ if (priorities.length)
370
+ add('priority = ANY(?::text[])', priorities);
371
+ if (projectCodes.length)
372
+ add('project_codes && ?::text[]', projectCodes);
373
+ if (assigneePersonId != null && assigneePersonId !== '')
374
+ add('assignee_person_id = ?', assigneePersonId);
375
+ const visible = this.visibility(filters, param);
376
+ if (visible)
377
+ conditions.push(visible);
378
+ if (after) {
379
+ const createdAt = param(after.createdAt);
380
+ const id = param(after.id);
381
+ conditions.push(`(created_at, id) < (${createdAt}::timestamptz, ${id}::uuid)`);
382
+ }
383
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
384
+ values.push(limit + 1);
385
+ const result = await this.query(`SELECT * FROM canonical_tasks ${where}
386
+ ORDER BY created_at DESC, id DESC
387
+ LIMIT $${values.length}`, values);
388
+ const hasMore = result.rows.length > limit;
389
+ const rows = result.rows.slice(0, limit);
390
+ return {
391
+ items: rows.map((row) => this.normalize(row)),
392
+ totalCount: null,
393
+ countStatus: 'not_requested',
394
+ readStatus: 'complete',
395
+ hasMore,
396
+ nextCursor: hasMore && rows.length ? encodeSearchCursor(rows.at(-1)) : null,
397
+ };
398
+ }
399
+ async get(taskId, storageScope) {
400
+ this.rejectStorageScope(storageScope);
401
+ const locator = this.decodeTaskLocator(taskId);
402
+ const result = await this.query(`SELECT * FROM canonical_tasks WHERE ${locator.column} = $1 LIMIT 1`, [locator.value]);
403
+ return this.normalize(result.rows[0] || null);
404
+ }
405
+ /** Tasks one meeting run created in one project, matched on the source reference. */
406
+ async listByMeetingMinutesRun({ projectCode, runId, limit = 50 } = {}) {
407
+ if (typeof projectCode !== 'string' || !projectCode || typeof runId !== 'string' || !runId)
408
+ return [];
409
+ const result = await this.query(`SELECT * FROM canonical_tasks
410
+ WHERE project_codes && $1::text[]
411
+ AND source_refs @> $2::jsonb
412
+ ORDER BY created_at ASC, id ASC
413
+ LIMIT $3`, [[projectCode], JSON.stringify([{ type: 'meeting_minutes', run_id: runId }]), limit]);
414
+ return result.rows.map((row) => this.normalize(row));
415
+ }
416
+ async findByIdempotencyKey(key, storageScope) {
417
+ this.rejectStorageScope(storageScope);
418
+ const result = await this.query('SELECT * FROM canonical_tasks WHERE idempotency_key = $1 LIMIT 1', [key]);
419
+ return this.normalize(result.rows[0] || null);
420
+ }
421
+ async create(input, storageScope) {
422
+ this.rejectStorageScope(storageScope);
423
+ const columns = [
424
+ ['id', randomUUID()],
425
+ ['title', input.title],
426
+ ['description', input.description ?? null],
427
+ ['status', input.status],
428
+ ['priority', input.priority],
429
+ ['assignee_person_id', input.assignee_person_id ?? null],
430
+ ['assignee_display_name', input.assignee_display_name ?? null],
431
+ ['due_at', input.due_at ?? null],
432
+ ['waiting_on', input.waiting_on ?? null],
433
+ ['review_at', input.review_at ?? null],
434
+ ['completed_at', input.completed_at ?? null],
435
+ ['source_refs', JSON.stringify(input.source_refs ?? []), '::jsonb'],
436
+ ['project_codes', input.project_codes ?? [], '::text[]'],
437
+ // Written only when given, so creating a Task keeps working on a
438
+ // database where an additive column has not been added yet.
439
+ ...(input.purpose_label != null ? [['purpose_label', input.purpose_label]] : []),
440
+ ...(input.waiting_on_person_id != null ? [['waiting_on_person_id', input.waiting_on_person_id]] : []),
441
+ ...(input.completion_contract != null
442
+ ? [['completion_contract', JSON.stringify(input.completion_contract), '::jsonb']]
443
+ : []),
444
+ ['version', input.version ?? 1],
445
+ ['idempotency_key', input.idempotency_key],
446
+ ['payload_fingerprint', input.payload_fingerprint ?? null],
447
+ ['last_operation_key', input.last_operation_key ?? null],
448
+ ['last_operation_fingerprint', input.last_operation_fingerprint ?? null],
449
+ ];
450
+ const result = await this.query(`INSERT INTO canonical_tasks (${columns.map(([column]) => column).join(', ')})
451
+ VALUES (${columns.map(([, , cast = ''], index) => `$${index + 1}${cast}`).join(', ')})
452
+ ON CONFLICT (idempotency_key) DO NOTHING
453
+ RETURNING *`, columns.map(([, value]) => value));
454
+ if (result.rows[0])
455
+ return this.normalize(result.rows[0]);
456
+ return (await this.findByIdempotencyKey(String(input.idempotency_key)));
457
+ }
458
+ async update(taskId, patch, storageScope) {
459
+ this.rejectStorageScope(storageScope);
460
+ // The public kernel marks an applied mutation with `_last_operation_key`
461
+ // and `_last_operation_fingerprint`. Persist them, or a retried update
462
+ // cannot be recognized as already applied and fails as a stale version.
463
+ const input = {
464
+ ...patch,
465
+ last_operation_key: patch.last_operation_key ?? patch._last_operation_key,
466
+ last_operation_fingerprint: patch.last_operation_fingerprint ?? patch._last_operation_fingerprint,
467
+ };
468
+ const locator = this.decodeTaskLocator(taskId);
469
+ const entries = Object.entries(MUTABLE_COLUMNS).filter(([field]) => input[field] !== undefined);
470
+ if (!entries.length)
471
+ return (await this.get(taskId));
472
+ // The kernel sends the next row version after validating the caller's
473
+ // expected_version. Convert it back so the UPDATE itself stays atomic.
474
+ // Legacy IDs keep their historical unversioned update behavior.
475
+ const proposedVersion = Number(input.version);
476
+ const expectedVersion = locator.column === 'id' && Number.isSafeInteger(proposedVersion) && proposedVersion > 1
477
+ ? proposedVersion - 1
478
+ : null;
479
+ const values = entries.map(([field]) => {
480
+ if (field === 'completion_contract')
481
+ return input[field] == null ? null : JSON.stringify(input[field]);
482
+ return field === 'source_refs' ? JSON.stringify(input[field]) : input[field];
483
+ });
484
+ const assignments = entries.map(([field, column], index) => `${column} = $${index + 1}${field === 'source_refs' || field === 'completion_contract' ? '::jsonb' : field === 'project_codes' ? '::text[]' : ''}`);
485
+ values.push(locator.value);
486
+ const locatorParameter = values.length;
487
+ if (expectedVersion !== null)
488
+ values.push(expectedVersion);
489
+ const versionParameter = values.length;
490
+ const apply = async (client) => {
491
+ const result = await client.query(`UPDATE canonical_tasks
492
+ SET ${assignments.join(', ')}, updated_at = NOW()
493
+ WHERE ${locator.column} = $${locatorParameter}
494
+ ${expectedVersion === null ? '' : `AND version = $${versionParameter}`}
495
+ RETURNING *`, values);
496
+ if (result.rows[0])
497
+ return this.normalize(result.rows[0]);
498
+ if (expectedVersion !== null) {
499
+ const current = await client.query(`SELECT * FROM canonical_tasks WHERE ${locator.column} = $1 LIMIT 1`, [locator.value]);
500
+ if (current.rows[0])
501
+ throw versionConflict(this.normalize(current.rows[0]));
502
+ }
503
+ throw notFound();
504
+ };
505
+ const completes = input.status === 'completed';
506
+ const changesCompletionContract = input.completion_contract !== undefined;
507
+ if (this.mutationGuard && locator.column === 'id' && (completes || changesCompletionContract)) {
508
+ return this.withTransaction(async (client) => {
509
+ // Typed children take this same row lock before they change, so the
510
+ // guard observes their serialized state.
511
+ const row = await client.query('SELECT id FROM canonical_tasks WHERE id = $1 LIMIT 1 FOR UPDATE', [locator.value]);
512
+ if (!row.rows[0])
513
+ throw notFound();
514
+ await this.mutationGuard.check({ client, rowId: locator.value, patch: input, completes, changesCompletionContract });
515
+ return apply(client);
516
+ });
517
+ }
518
+ return apply({ query: (text, queryValues) => this.query(text, queryValues) });
519
+ }
520
+ async listPurposeLabels({ projectCode, assigneePersonId, limit = 200 }) {
521
+ const values = [[projectCode]];
522
+ const conditions = ['project_codes && $1::text[]', 'purpose_label IS NOT NULL'];
523
+ if (assigneePersonId !== undefined) {
524
+ values.push(assigneePersonId);
525
+ conditions.push(`assignee_person_id IS NOT DISTINCT FROM $${values.length}`);
526
+ }
527
+ values.push(limit + 1);
528
+ const result = await this.query(`SELECT purpose_label, COUNT(*)::integer AS task_count
529
+ FROM canonical_tasks
530
+ WHERE ${conditions.join(' AND ')}
531
+ GROUP BY purpose_label
532
+ ORDER BY task_count DESC, purpose_label ASC
533
+ LIMIT $${values.length}`, values);
534
+ const rows = result.rows.slice(0, limit);
535
+ return {
536
+ labels: rows.map((row) => ({ purpose_label: String(row.purpose_label), task_count: Number(row.task_count) })),
537
+ truncated: result.rows.length > limit,
538
+ };
539
+ }
540
+ async delete(taskId, expectedVersion, storageScope) {
541
+ this.rejectStorageScope(storageScope);
542
+ const locator = this.decodeTaskLocator(taskId);
543
+ if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1) {
544
+ throw storeError('validation_failed', 'Expected Task version is required', 422);
545
+ }
546
+ const result = await this.query(`DELETE FROM canonical_tasks WHERE ${locator.column} = $1 AND version = $2 RETURNING id`, [locator.value, expectedVersion]);
547
+ if (result.rows[0])
548
+ return;
549
+ const currentTask = await this.get(taskId);
550
+ if (!currentTask)
551
+ throw notFound();
552
+ throw versionConflict(currentTask);
553
+ }
554
+ }
@@ -124,6 +124,8 @@ export interface CanonicalTaskAuditEntry extends JsonRecord {
124
124
  source_refs: unknown[];
125
125
  }
126
126
  export interface CanonicalTaskRepository {
127
+ /** Durable PostgreSQL repositories require a release readiness binding. */
128
+ requiresReadiness?: boolean;
127
129
  list(filters: CanonicalTaskListFilters, storageScope?: string): Promise<CanonicalTaskPage>;
128
130
  search(filters: CanonicalTaskSearchFilters, storageScope?: string): Promise<CanonicalTaskPage>;
129
131
  get(taskId: string, storageScope?: string): Promise<CanonicalTaskRecord | null>;
@@ -189,10 +191,18 @@ export interface CanonicalTaskPreparedDeleteRequest<T> {
189
191
  }>;
190
192
  findTask: () => Promise<CanonicalTaskRecord | null>;
191
193
  removeTask: (task?: CanonicalTaskRecord) => Promise<void>;
194
+ /** Reauthorize a completed or already-applied delete from its durable snapshot. */
195
+ authorizeReplay?: (authorizationSnapshot: JsonRecord, result: T) => Promise<void>;
192
196
  projectResult?: (result: T) => unknown;
193
197
  recover?: () => Promise<CanonicalTaskOperationRecovery<T>>;
194
198
  }
199
+ export interface CanonicalTaskMutationReadiness {
200
+ /** Reject the mutation unless the persisted release readiness still matches. */
201
+ assertMutationReady(): Promise<void>;
202
+ }
195
203
  export interface CanonicalTaskOperationRepository {
204
+ /** Durable PostgreSQL coordinators set this marker and require readiness binding. */
205
+ requiresReadiness?: boolean;
196
206
  execute<T>(request: CanonicalTaskOperationRequest<T>): Promise<T>;
197
207
  executePreparedDelete?<T>(request: CanonicalTaskPreparedDeleteRequest<T>): Promise<T>;
198
208
  }
@@ -201,6 +211,7 @@ export interface CanonicalTaskServiceOptions {
201
211
  auditRepository?: CanonicalTaskAuditRepository;
202
212
  policy?: CanonicalTaskPolicy;
203
213
  operationRepository?: CanonicalTaskOperationRepository;
214
+ readiness?: CanonicalTaskMutationReadiness | null;
204
215
  clock?: CanonicalTaskClock;
205
216
  baseUrl?: CanonicalTaskBaseUrl;
206
217
  }
@@ -220,6 +231,7 @@ export declare class CanonicalTaskService {
220
231
  private readonly auditRepository?;
221
232
  private readonly policy;
222
233
  private readonly operationRepository?;
234
+ private readonly readiness?;
223
235
  private readonly clock;
224
236
  private readonly baseUrl?;
225
237
  constructor(options: CanonicalTaskServiceOptions);
@@ -265,6 +277,7 @@ export declare class CanonicalTaskService {
265
277
  private scopeArgs;
266
278
  private normalizeContext;
267
279
  private authorize;
280
+ private assertMutationReady;
268
281
  private scopeFilters;
269
282
  private requireIdempotencyKey;
270
283
  private requireExpectedVersion;