@dsh-enhanced/assistant-delivery 0.1.7 → 0.1.12

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 (59) hide show
  1. package/README.md +89 -10
  2. package/cordis.patch.yml +1 -0
  3. package/lib/agent-runtime.d.ts +18 -4
  4. package/lib/agent-runtime.d.ts.map +1 -1
  5. package/lib/agent-runtime.js +240 -14
  6. package/lib/agent-runtime.js.map +1 -1
  7. package/lib/canonical.d.ts +11 -1
  8. package/lib/canonical.d.ts.map +1 -1
  9. package/lib/canonical.js +107 -0
  10. package/lib/canonical.js.map +1 -1
  11. package/lib/coordinator.d.ts +3 -1
  12. package/lib/coordinator.d.ts.map +1 -1
  13. package/lib/coordinator.js +49 -21
  14. package/lib/coordinator.js.map +1 -1
  15. package/lib/feedback-command.d.ts +48 -2
  16. package/lib/feedback-command.d.ts.map +1 -1
  17. package/lib/feedback-command.js +226 -10
  18. package/lib/feedback-command.js.map +1 -1
  19. package/lib/index.d.ts +4 -3
  20. package/lib/index.d.ts.map +1 -1
  21. package/lib/index.js +4 -3
  22. package/lib/index.js.map +1 -1
  23. package/lib/learning-command.d.ts +40 -0
  24. package/lib/learning-command.d.ts.map +1 -0
  25. package/lib/learning-command.js +57 -0
  26. package/lib/learning-command.js.map +1 -0
  27. package/lib/service.d.ts +142 -10
  28. package/lib/service.d.ts.map +1 -1
  29. package/lib/service.js +1841 -62
  30. package/lib/service.js.map +1 -1
  31. package/lib/session-commands.d.ts +14 -0
  32. package/lib/session-commands.d.ts.map +1 -1
  33. package/lib/session-commands.js +30 -0
  34. package/lib/session-commands.js.map +1 -1
  35. package/lib/sqlite.d.ts +1 -1
  36. package/lib/sqlite.d.ts.map +1 -1
  37. package/lib/sqlite.js +648 -20
  38. package/lib/sqlite.js.map +1 -1
  39. package/lib/store.d.ts +375 -3
  40. package/lib/store.d.ts.map +1 -1
  41. package/lib/store.js +2956 -74
  42. package/lib/store.js.map +1 -1
  43. package/lib/types.d.ts +364 -2
  44. package/lib/types.d.ts.map +1 -1
  45. package/lib/types.js +9 -1
  46. package/lib/types.js.map +1 -1
  47. package/lib/version.d.ts +1 -1
  48. package/lib/version.d.ts.map +1 -1
  49. package/lib/version.js +1 -1
  50. package/lib/version.js.map +1 -1
  51. package/lib/workflow-auto-producer.d.ts +37 -0
  52. package/lib/workflow-auto-producer.d.ts.map +1 -0
  53. package/lib/workflow-auto-producer.js +58 -0
  54. package/lib/workflow-auto-producer.js.map +1 -0
  55. package/lib/workflow-command.d.ts +16 -0
  56. package/lib/workflow-command.d.ts.map +1 -0
  57. package/lib/workflow-command.js +52 -0
  58. package/lib/workflow-command.js.map +1 -0
  59. package/package.json +23 -3
package/lib/sqlite.js CHANGED
@@ -1,7 +1,381 @@
1
1
  import { chmodSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, isAbsolute } from 'node:path';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
- export const deliverySchemaVersion = 8;
4
+ export const deliverySchemaVersion = 16;
5
+ /**
6
+ * A database-local, durable total order for every admitted Inbox. The trigger
7
+ * deliberately lives in SQLite so an already-open v12 process also receives
8
+ * a cursor after a v13 process migrates the shared database.
9
+ */
10
+ const inboxAdmissionSchema = `
11
+ CREATE TABLE IF NOT EXISTS delivery_inbox_admission_clock (
12
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
13
+ current_sequence INTEGER NOT NULL CHECK (
14
+ current_sequence BETWEEN 0 AND 9007199254740991
15
+ )
16
+ ) STRICT;
17
+ INSERT INTO delivery_inbox_admission_clock(singleton, current_sequence)
18
+ VALUES (1, 0) ON CONFLICT(singleton) DO NOTHING;
19
+
20
+ CREATE TABLE IF NOT EXISTS delivery_inbox_admissions (
21
+ inbox_id TEXT PRIMARY KEY,
22
+ epoch TEXT NOT NULL CHECK (
23
+ length(epoch) = 32 AND epoch NOT GLOB '*[^0-9a-f]*'
24
+ ),
25
+ admission_sequence INTEGER NOT NULL UNIQUE CHECK (
26
+ admission_sequence BETWEEN 1 AND 9007199254740991
27
+ ),
28
+ FOREIGN KEY (inbox_id) REFERENCES inbox_messages(id) ON DELETE CASCADE
29
+ ) STRICT;
30
+
31
+ INSERT OR IGNORE INTO delivery_inbox_admissions(inbox_id, epoch, admission_sequence)
32
+ SELECT message.id, instance.instance_id,
33
+ ROW_NUMBER() OVER (ORDER BY message.rowid)
34
+ FROM inbox_messages AS message
35
+ CROSS JOIN delivery_instance AS instance
36
+ WHERE instance.singleton = 1;
37
+
38
+ UPDATE delivery_inbox_admission_clock
39
+ SET current_sequence = MAX(current_sequence,
40
+ (SELECT COALESCE(MAX(admission_sequence), 0) FROM delivery_inbox_admissions))
41
+ WHERE singleton = 1;
42
+
43
+ CREATE TRIGGER IF NOT EXISTS delivery_inbox_admission_after_insert
44
+ AFTER INSERT ON inbox_messages
45
+ BEGIN
46
+ UPDATE delivery_inbox_admission_clock
47
+ SET current_sequence = current_sequence + 1
48
+ WHERE singleton = 1;
49
+ INSERT INTO delivery_inbox_admissions(inbox_id, epoch, admission_sequence)
50
+ SELECT NEW.id, instance.instance_id, clock.current_sequence
51
+ FROM delivery_instance AS instance
52
+ CROSS JOIN delivery_inbox_admission_clock AS clock
53
+ WHERE instance.singleton = 1 AND clock.singleton = 1;
54
+ END;
55
+ `;
56
+ const preferenceProjectionSchema = `
57
+ CREATE TABLE delivery_preference_projection_outbox (
58
+ batch_key TEXT PRIMARY KEY,
59
+ payload_digest TEXT NOT NULL CHECK (
60
+ length(payload_digest) = 64 AND payload_digest NOT GLOB '*[^0-9a-f]*'
61
+ ),
62
+ events_json TEXT NOT NULL CHECK (
63
+ json_valid(events_json) AND json_type(events_json) = 'array'
64
+ AND json_array_length(events_json) BETWEEN 1 AND 16
65
+ ),
66
+ status TEXT NOT NULL CHECK (status IN ('pending', 'retry_wait')),
67
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
68
+ next_attempt_at INTEGER NOT NULL,
69
+ failure_code TEXT,
70
+ lane_kind TEXT NOT NULL DEFAULT 'unclassified' CHECK (
71
+ lane_kind IN ('exact', 'legacy', 'unclassified')
72
+ ),
73
+ lane_epoch TEXT CHECK (
74
+ lane_epoch IS NULL OR (
75
+ length(lane_epoch) = 32 AND lane_epoch NOT GLOB '*[^0-9a-f]*'
76
+ )
77
+ ),
78
+ lane_workspace TEXT,
79
+ lane_preset TEXT,
80
+ lane_principal_record_id TEXT,
81
+ lane_principal_version INTEGER CHECK (
82
+ lane_principal_version IS NULL OR lane_principal_version >= 1
83
+ ),
84
+ admission_sequence INTEGER CHECK (
85
+ admission_sequence IS NULL OR admission_sequence BETWEEN 1 AND 9007199254740991
86
+ ),
87
+ terminal_at INTEGER CHECK (terminal_at IS NULL OR terminal_at >= 0),
88
+ created_at INTEGER NOT NULL,
89
+ updated_at INTEGER NOT NULL
90
+ ) STRICT;
91
+ CREATE INDEX delivery_preference_projection_due
92
+ ON delivery_preference_projection_outbox(
93
+ terminal_at, lane_kind, status, next_attempt_at, updated_at, batch_key
94
+ );
95
+ CREATE INDEX delivery_preference_projection_lane
96
+ ON delivery_preference_projection_outbox(
97
+ terminal_at, lane_epoch, lane_workspace, lane_preset,
98
+ lane_principal_record_id, lane_principal_version, admission_sequence,
99
+ created_at, batch_key
100
+ );
101
+ `;
102
+ const workflowTraceSchema = `
103
+ CREATE TABLE workflow_trace_source (
104
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
105
+ contract_version INTEGER NOT NULL CHECK (contract_version = 1),
106
+ generation INTEGER NOT NULL CHECK (generation >= 1),
107
+ authority_digest TEXT NOT NULL CHECK (
108
+ length(authority_digest) = 64 AND authority_digest NOT GLOB '*[^0-9a-f]*'
109
+ ),
110
+ created_at INTEGER NOT NULL,
111
+ updated_at INTEGER NOT NULL
112
+ ) STRICT;
113
+
114
+ CREATE TABLE workflow_template_registry (
115
+ template_ref TEXT PRIMARY KEY,
116
+ template_digest TEXT NOT NULL CHECK (
117
+ length(template_digest) = 64 AND template_digest NOT GLOB '*[^0-9a-f]*'
118
+ ),
119
+ scope_key TEXT NOT NULL,
120
+ workspace TEXT NOT NULL,
121
+ preset TEXT NOT NULL,
122
+ owner_binding_id TEXT NOT NULL,
123
+ content_json TEXT NOT NULL,
124
+ privacy_kind TEXT NOT NULL CHECK (
125
+ privacy_kind IN ('deterministic-deidentification', 'owner-explicit')
126
+ ),
127
+ privacy_attestation_id TEXT NOT NULL UNIQUE,
128
+ privacy_attestation_digest TEXT NOT NULL CHECK (
129
+ length(privacy_attestation_digest) = 64
130
+ AND privacy_attestation_digest NOT GLOB '*[^0-9a-f]*'
131
+ ),
132
+ review_receipt_json TEXT NOT NULL,
133
+ status TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
134
+ review_inbox_id TEXT NOT NULL,
135
+ source_inbox_id TEXT NOT NULL,
136
+ source_outbox_id TEXT NOT NULL,
137
+ created_at INTEGER NOT NULL,
138
+ updated_at INTEGER NOT NULL,
139
+ version INTEGER NOT NULL CHECK (version >= 1),
140
+ FOREIGN KEY (owner_binding_id) REFERENCES conversation_bindings(id),
141
+ FOREIGN KEY (review_inbox_id) REFERENCES inbox_messages(id),
142
+ FOREIGN KEY (source_inbox_id) REFERENCES inbox_messages(id),
143
+ FOREIGN KEY (source_outbox_id) REFERENCES outbox_messages(id)
144
+ ) STRICT;
145
+ CREATE INDEX workflow_template_scope
146
+ ON workflow_template_registry(scope_key, status, updated_at, template_ref);
147
+
148
+ CREATE TABLE workflow_trace_revisions (
149
+ subject_ref TEXT NOT NULL,
150
+ version INTEGER NOT NULL CHECK (version >= 1),
151
+ source_generation INTEGER NOT NULL CHECK (source_generation >= 1),
152
+ source_authority_digest TEXT NOT NULL CHECK (
153
+ length(source_authority_digest) = 64
154
+ AND source_authority_digest NOT GLOB '*[^0-9a-f]*'
155
+ ),
156
+ scope_key TEXT NOT NULL,
157
+ workspace TEXT NOT NULL,
158
+ preset TEXT NOT NULL,
159
+ disposition TEXT NOT NULL CHECK (disposition IN ('upsert', 'retract')),
160
+ digest TEXT NOT NULL CHECK (length(digest) = 64 AND digest NOT GLOB '*[^0-9a-f]*'),
161
+ payload_json TEXT NOT NULL,
162
+ created_at INTEGER NOT NULL,
163
+ PRIMARY KEY (subject_ref, version)
164
+ ) STRICT;
165
+
166
+ CREATE TABLE workflow_trace_current (
167
+ subject_ref TEXT PRIMARY KEY,
168
+ version INTEGER NOT NULL CHECK (version >= 1),
169
+ digest TEXT NOT NULL,
170
+ disposition TEXT NOT NULL CHECK (disposition IN ('upsert', 'retract')),
171
+ payload_json TEXT NOT NULL,
172
+ updated_at INTEGER NOT NULL,
173
+ FOREIGN KEY (subject_ref, version) REFERENCES workflow_trace_revisions(subject_ref, version)
174
+ ) STRICT;
175
+
176
+ CREATE TABLE workflow_trace_outbox (
177
+ subject_ref TEXT NOT NULL,
178
+ version INTEGER NOT NULL CHECK (version >= 1),
179
+ status TEXT NOT NULL CHECK (status IN ('pending', 'delivered', 'retry_wait')),
180
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
181
+ next_attempt_at INTEGER NOT NULL,
182
+ failure_code TEXT,
183
+ created_at INTEGER NOT NULL,
184
+ updated_at INTEGER NOT NULL,
185
+ PRIMARY KEY (subject_ref, version),
186
+ FOREIGN KEY (subject_ref, version) REFERENCES workflow_trace_revisions(subject_ref, version)
187
+ ) STRICT;
188
+ CREATE INDEX workflow_trace_outbox_due
189
+ ON workflow_trace_outbox(status, next_attempt_at, updated_at, subject_ref, version);
190
+
191
+ CREATE TABLE workflow_trace_commands (
192
+ operation_id TEXT PRIMARY KEY,
193
+ payload_digest TEXT NOT NULL CHECK (
194
+ length(payload_digest) = 64 AND payload_digest NOT GLOB '*[^0-9a-f]*'
195
+ ),
196
+ result_json TEXT NOT NULL,
197
+ created_at INTEGER NOT NULL
198
+ ) STRICT;
199
+
200
+ CREATE TABLE trusted_delivery_evaluation_outbox (
201
+ idempotency_key TEXT PRIMARY KEY,
202
+ payload_digest TEXT NOT NULL CHECK (
203
+ length(payload_digest) = 64 AND payload_digest NOT GLOB '*[^0-9a-f]*'
204
+ ),
205
+ claims_json TEXT NOT NULL,
206
+ status TEXT NOT NULL CHECK (status IN ('pending', 'retry_wait', 'delivered')),
207
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
208
+ next_attempt_at INTEGER NOT NULL,
209
+ failure_code TEXT,
210
+ created_at INTEGER NOT NULL,
211
+ updated_at INTEGER NOT NULL
212
+ ) STRICT;
213
+ CREATE INDEX trusted_delivery_evaluation_due
214
+ ON trusted_delivery_evaluation_outbox(status, next_attempt_at, updated_at, idempotency_key);
215
+ `;
216
+ /**
217
+ * One authenticated owner objective judgement for one ordinary Delivery turn.
218
+ * This is intentionally separate from Evaluation's cross-plugin ledger: the
219
+ * local receipt is the atomic proof coupled to a WorkflowTrace, so a restart
220
+ * cannot leave a supposedly verified trace without its exact owner fence.
221
+ */
222
+ const workflowVerifiedTaskFeedbackSchema = `
223
+ CREATE TABLE IF NOT EXISTS workflow_verified_task_feedback (
224
+ source_outbox_id TEXT PRIMARY KEY,
225
+ source_inbox_id TEXT NOT NULL UNIQUE,
226
+ feedback_inbox_id TEXT NOT NULL UNIQUE,
227
+ binding_id TEXT NOT NULL,
228
+ binding_version INTEGER NOT NULL CHECK (binding_version >= 1),
229
+ binding_generation INTEGER NOT NULL CHECK (binding_generation >= 1),
230
+ principal_record_id TEXT NOT NULL,
231
+ objective_status TEXT NOT NULL CHECK (
232
+ objective_status IN ('achieved', 'partial', 'not-achieved')
233
+ ),
234
+ task_ref TEXT NOT NULL CHECK (
235
+ length(task_ref) = 64 AND task_ref NOT GLOB '*[^0-9a-f]*'
236
+ ),
237
+ task_evidence_digest TEXT NOT NULL CHECK (
238
+ length(task_evidence_digest) = 64
239
+ AND task_evidence_digest NOT GLOB '*[^0-9a-f]*'
240
+ ),
241
+ trace_subject_ref TEXT CHECK (
242
+ trace_subject_ref IS NULL
243
+ OR (length(trace_subject_ref) = 64 AND trace_subject_ref NOT GLOB '*[^0-9a-f]*')
244
+ ),
245
+ trace_version INTEGER CHECK (trace_version IS NULL OR trace_version >= 1),
246
+ trace_digest TEXT CHECK (
247
+ trace_digest IS NULL
248
+ OR (length(trace_digest) = 64 AND trace_digest NOT GLOB '*[^0-9a-f]*')
249
+ ),
250
+ template_ref TEXT,
251
+ created_at INTEGER NOT NULL,
252
+ CHECK (
253
+ (trace_subject_ref IS NOT NULL AND trace_version IS NOT NULL
254
+ AND trace_digest IS NOT NULL AND template_ref IS NOT NULL)
255
+ OR
256
+ (trace_subject_ref IS NULL AND trace_version IS NULL
257
+ AND trace_digest IS NULL AND template_ref IS NULL)
258
+ ),
259
+ FOREIGN KEY (source_outbox_id) REFERENCES outbox_messages(id),
260
+ FOREIGN KEY (source_inbox_id) REFERENCES inbox_messages(id),
261
+ FOREIGN KEY (feedback_inbox_id) REFERENCES inbox_messages(id),
262
+ FOREIGN KEY (binding_id) REFERENCES conversation_bindings(id),
263
+ FOREIGN KEY (principal_record_id) REFERENCES delivery_principals(id),
264
+ FOREIGN KEY (template_ref) REFERENCES workflow_template_registry(template_ref),
265
+ FOREIGN KEY (trace_subject_ref, trace_version)
266
+ REFERENCES workflow_trace_revisions(subject_ref, version)
267
+ ) STRICT;
268
+ CREATE INDEX IF NOT EXISTS workflow_verified_task_feedback_binding
269
+ ON workflow_verified_task_feedback(binding_id, created_at, source_outbox_id);
270
+ `;
271
+ const deliveryPresentationSchema = `
272
+ CREATE TABLE IF NOT EXISTS delivery_presentations (
273
+ presentation_key TEXT PRIMARY KEY,
274
+ original_outbox_idempotency_key TEXT NOT NULL,
275
+ revision INTEGER NOT NULL CHECK (revision >= 1),
276
+ payload_hash TEXT NOT NULL,
277
+ payload_json TEXT NOT NULL,
278
+ status TEXT NOT NULL CHECK (
279
+ status IN ('pending', 'attempting', 'presented', 'retry_wait', 'dead')
280
+ ),
281
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
282
+ presented_revision INTEGER NOT NULL DEFAULT 0 CHECK (presented_revision >= 0),
283
+ next_attempt_at INTEGER,
284
+ claimed_by TEXT,
285
+ fencing_token INTEGER,
286
+ lease_until INTEGER,
287
+ provider_message_id TEXT,
288
+ failure_code TEXT,
289
+ created_at INTEGER NOT NULL,
290
+ updated_at INTEGER NOT NULL
291
+ ) STRICT;
292
+ CREATE INDEX IF NOT EXISTS delivery_presentation_claim
293
+ ON delivery_presentations(status, next_attempt_at, lease_until, created_at, presentation_key);
294
+ `;
295
+ const deadLetterResolutionSchema = `
296
+ CREATE TABLE dead_letter_resolutions (
297
+ kind TEXT NOT NULL CHECK (kind IN ('inbox', 'outbox')),
298
+ message_id TEXT NOT NULL,
299
+ attempt_count INTEGER NOT NULL CHECK (attempt_count >= 0),
300
+ receipt_version INTEGER NOT NULL CHECK (receipt_version = 1),
301
+ resolution TEXT NOT NULL CHECK (resolution IN ('cancel', 'retry')),
302
+ original_status TEXT NOT NULL CHECK (
303
+ (kind = 'inbox' AND original_status = 'dead_letter')
304
+ OR (kind = 'outbox' AND original_status IN ('dead', 'unknown_after_send'))
305
+ ),
306
+ original_failure_code TEXT,
307
+ operator_id TEXT NOT NULL,
308
+ created_at INTEGER NOT NULL,
309
+ PRIMARY KEY (kind, message_id, attempt_count)
310
+ ) STRICT;
311
+ CREATE INDEX dead_letter_resolution_projection
312
+ ON dead_letter_resolutions(kind, resolution, message_id, attempt_count);
313
+ `;
314
+ // These triggers are a rolling-upgrade fence for a v8 process that opened the
315
+ // database before v9 changed PRAGMA user_version. SQLite does not evict that
316
+ // already-open writer. A v9 writer first persists the exact operator receipt
317
+ // in the same transaction, so any receipt-less v8 resolution is rejected.
318
+ const deadLetterResolutionCompatibilitySchema = `
319
+ CREATE TRIGGER dead_letter_inbox_resolution_fence
320
+ BEFORE UPDATE ON inbox_messages
321
+ WHEN OLD.status = 'dead_letter' AND NEW.attempt_count = OLD.attempt_count AND (
322
+ NEW.status = 'queued'
323
+ OR (NEW.status = 'dead_letter' AND NEW.failure_code = 'operator-cancelled')
324
+ ) AND NOT EXISTS (
325
+ SELECT 1 FROM dead_letter_resolutions AS resolution
326
+ WHERE resolution.kind = 'inbox' AND resolution.message_id = OLD.id
327
+ AND resolution.attempt_count = OLD.attempt_count
328
+ AND resolution.resolution = CASE WHEN NEW.status = 'queued' THEN 'retry' ELSE 'cancel' END
329
+ AND resolution.original_status = OLD.status
330
+ AND resolution.original_failure_code IS OLD.failure_code
331
+ )
332
+ BEGIN
333
+ SELECT RAISE(ABORT, 'inbox resolution requires an exact v9 receipt');
334
+ END;
335
+
336
+ CREATE TRIGGER dead_letter_outbox_resolution_fence
337
+ BEFORE UPDATE ON outbox_messages
338
+ WHEN OLD.status IN ('dead', 'unknown_after_send')
339
+ AND NEW.attempt_count = OLD.attempt_count AND (
340
+ NEW.status = 'pending'
341
+ OR (NEW.status = 'dead' AND NEW.failure_code = 'operator-cancelled')
342
+ OR (NEW.status = 'unknown_after_send' AND NEW.failure_code = 'operator-cancelled-unknown')
343
+ ) AND NOT EXISTS (
344
+ SELECT 1 FROM dead_letter_resolutions AS resolution
345
+ WHERE resolution.kind = 'outbox' AND resolution.message_id = OLD.id
346
+ AND resolution.attempt_count = OLD.attempt_count
347
+ AND resolution.resolution = CASE WHEN NEW.status = 'pending' THEN 'retry' ELSE 'cancel' END
348
+ AND resolution.original_status = OLD.status
349
+ AND resolution.original_failure_code IS OLD.failure_code
350
+ )
351
+ BEGIN
352
+ SELECT RAISE(ABORT, 'outbox resolution requires an exact v9 receipt');
353
+ END;
354
+
355
+ CREATE TRIGGER dead_letter_outbox_cancelled_unknown_fence
356
+ BEFORE UPDATE ON outbox_messages
357
+ WHEN OLD.status = 'unknown_after_send' AND EXISTS (
358
+ SELECT 1 FROM dead_letter_resolutions AS resolution
359
+ WHERE resolution.kind = 'outbox' AND resolution.message_id = OLD.id
360
+ AND resolution.attempt_count = OLD.attempt_count AND resolution.resolution = 'cancel'
361
+ AND resolution.original_status = 'unknown_after_send'
362
+ ) AND NOT (
363
+ NEW.attempt_count = OLD.attempt_count AND (
364
+ (NEW.status = 'unknown_after_send' AND NEW.failure_code = 'operator-cancelled-unknown')
365
+ OR (
366
+ NEW.status IN ('accepted', 'delivered', 'read') AND EXISTS (
367
+ SELECT 1 FROM delivery_receipts AS receipt
368
+ WHERE receipt.channel = OLD.channel AND receipt.account = OLD.account
369
+ AND receipt.provider_message_id = OLD.provider_message_id
370
+ AND receipt.status = NEW.status
371
+ )
372
+ )
373
+ )
374
+ )
375
+ BEGIN
376
+ SELECT RAISE(ABORT, 'cancelled unknown outbox attempt cannot be reclaimed');
377
+ END;
378
+ `;
5
379
  const deliveryInstanceSchema = `
6
380
  CREATE TABLE delivery_instance (
7
381
  singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
@@ -22,6 +396,58 @@ const approvalDispatchCursorSchema = `
22
396
  CHECK ((after_created_at IS NULL) = (after_proposal_id IS NULL))
23
397
  ) STRICT;
24
398
  `;
399
+ /** Immutable v2 route receipt coupled to the approval Outbox insertion. */
400
+ const approvalOutboxRouteSchema = `
401
+ CREATE TABLE IF NOT EXISTS approval_outbox_routes (
402
+ outbox_id TEXT PRIMARY KEY,
403
+ route_version INTEGER NOT NULL CHECK (route_version = 2),
404
+ source_id TEXT NOT NULL,
405
+ binding_id TEXT NOT NULL,
406
+ binding_version INTEGER NOT NULL CHECK (binding_version >= 1),
407
+ binding_generation INTEGER NOT NULL CHECK (binding_generation >= 1),
408
+ workspace TEXT NOT NULL,
409
+ principal TEXT NOT NULL,
410
+ principal_record_id TEXT NOT NULL,
411
+ principal_version INTEGER NOT NULL CHECK (principal_version >= 1),
412
+ FOREIGN KEY (outbox_id) REFERENCES outbox_messages(id) ON DELETE CASCADE,
413
+ FOREIGN KEY (binding_id) REFERENCES conversation_bindings(id),
414
+ FOREIGN KEY (principal_record_id) REFERENCES delivery_principals(id)
415
+ ) STRICT;
416
+ CREATE INDEX IF NOT EXISTS approval_outbox_route_binding
417
+ ON approval_outbox_routes(binding_id, binding_version, binding_generation);
418
+ `;
419
+ function assertApprovalOutboxRouteSchema(database) {
420
+ const columns = database.prepare('PRAGMA table_info(approval_outbox_routes)').all();
421
+ const expected = [
422
+ ['outbox_id', 'TEXT', 1, 1],
423
+ ['route_version', 'INTEGER', 1, 0],
424
+ ['source_id', 'TEXT', 1, 0],
425
+ ['binding_id', 'TEXT', 1, 0],
426
+ ['binding_version', 'INTEGER', 1, 0],
427
+ ['binding_generation', 'INTEGER', 1, 0],
428
+ ['workspace', 'TEXT', 1, 0],
429
+ ['principal', 'TEXT', 1, 0],
430
+ ['principal_record_id', 'TEXT', 1, 0],
431
+ ['principal_version', 'INTEGER', 1, 0],
432
+ ];
433
+ if (columns.length !== expected.length || expected.some((entry, index) => {
434
+ const column = columns[index];
435
+ return column === undefined || column.name !== entry[0] || column.type !== entry[1]
436
+ || column.notnull !== entry[2] || column.pk !== entry[3];
437
+ })) {
438
+ throw new Error('delivery approval route receipt schema is invalid');
439
+ }
440
+ const foreignKeys = database.prepare('PRAGMA foreign_key_list(approval_outbox_routes)').all();
441
+ const expectedForeignKeys = [
442
+ ['binding_id', 'conversation_bindings', 'id'],
443
+ ['outbox_id', 'outbox_messages', 'id'],
444
+ ['principal_record_id', 'delivery_principals', 'id'],
445
+ ];
446
+ const actualForeignKeys = foreignKeys.map(row => [row.from, row.table, row.to]).sort();
447
+ if (JSON.stringify(actualForeignKeys) !== JSON.stringify(expectedForeignKeys)) {
448
+ throw new Error('delivery approval route receipt foreign keys are invalid');
449
+ }
450
+ }
25
451
  const modelPickerStateSchema = `
26
452
  CREATE TABLE conversation_model_epochs (
27
453
  conversation_hash TEXT PRIMARY KEY,
@@ -72,7 +498,71 @@ export class DeliveryDatabaseError extends Error {
72
498
  this.name = 'DeliveryDatabaseError';
73
499
  }
74
500
  }
75
- function migrate(database) {
501
+ function hasTable(database, name) {
502
+ return database.prepare(`
503
+ SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?
504
+ `).get(name) !== undefined;
505
+ }
506
+ function hasColumn(database, table, column) {
507
+ return database.prepare(`PRAGMA table_info(${table})`).all()
508
+ .some(entry => entry.name === column);
509
+ }
510
+ function migratePreferenceProjectionLane(database) {
511
+ const lockedVersion = database.prepare('PRAGMA user_version').get()
512
+ .user_version;
513
+ if (lockedVersion >= 14)
514
+ return;
515
+ if (lockedVersion !== 13) {
516
+ throw new Error(`delivery preference lane migration expected schema 13, received ${lockedVersion}`);
517
+ }
518
+ if (!hasTable(database, 'delivery_preference_projection_outbox')) {
519
+ database.exec('PRAGMA user_version = 14');
520
+ return;
521
+ }
522
+ const additions = [
523
+ ['lane_kind', `TEXT NOT NULL DEFAULT 'unclassified' CHECK (
524
+ lane_kind IN ('exact', 'legacy', 'unclassified')
525
+ )`],
526
+ ['lane_epoch', `TEXT CHECK (
527
+ lane_epoch IS NULL OR (
528
+ length(lane_epoch) = 32 AND lane_epoch NOT GLOB '*[^0-9a-f]*'
529
+ )
530
+ )`],
531
+ ['lane_workspace', 'TEXT'],
532
+ ['lane_preset', 'TEXT'],
533
+ ['lane_principal_record_id', 'TEXT'],
534
+ ['lane_principal_version', `INTEGER CHECK (
535
+ lane_principal_version IS NULL OR lane_principal_version >= 1
536
+ )`],
537
+ ['admission_sequence', `INTEGER CHECK (
538
+ admission_sequence IS NULL OR admission_sequence BETWEEN 1 AND 9007199254740991
539
+ )`],
540
+ ['terminal_at', 'INTEGER CHECK (terminal_at IS NULL OR terminal_at >= 0)'],
541
+ ];
542
+ const alter = additions
543
+ .filter(([column]) => !hasColumn(database, 'delivery_preference_projection_outbox', column))
544
+ .map(([column, definition]) => `
545
+ ALTER TABLE delivery_preference_projection_outbox
546
+ ADD COLUMN ${column} ${definition};
547
+ `)
548
+ .join('');
549
+ database.exec(`
550
+ ${alter}
551
+ DROP INDEX IF EXISTS delivery_preference_projection_due;
552
+ CREATE INDEX delivery_preference_projection_due
553
+ ON delivery_preference_projection_outbox(
554
+ terminal_at, lane_kind, status, next_attempt_at, updated_at, batch_key
555
+ );
556
+ CREATE INDEX IF NOT EXISTS delivery_preference_projection_lane
557
+ ON delivery_preference_projection_outbox(
558
+ terminal_at, lane_epoch, lane_workspace, lane_preset,
559
+ lane_principal_record_id, lane_principal_version, admission_sequence,
560
+ created_at, batch_key
561
+ );
562
+ PRAGMA user_version = 14;
563
+ `);
564
+ }
565
+ function migrateObserved(database) {
76
566
  const row = database.prepare('PRAGMA user_version').get();
77
567
  let version = row.user_version;
78
568
  if (version > deliverySchemaVersion) {
@@ -82,18 +572,15 @@ function migrate(database) {
82
572
  return;
83
573
  if (version === 1) {
84
574
  database.exec(`
85
- BEGIN IMMEDIATE;
86
575
  ALTER TABLE delivery_attachments ADD COLUMN resource_kind TEXT;
87
576
  ALTER TABLE delivery_attachments ADD COLUMN provider_ref TEXT;
88
577
  ALTER TABLE delivery_attachments ADD COLUMN file_name TEXT;
89
578
  PRAGMA user_version = 2;
90
- COMMIT;
91
579
  `);
92
580
  version = 2;
93
581
  }
94
582
  if (version === 2) {
95
583
  database.exec(`
96
- BEGIN IMMEDIATE;
97
584
  CREATE TABLE conversation_model_selections (
98
585
  conversation_hash TEXT PRIMARY KEY,
99
586
  conversation_json TEXT NOT NULL,
@@ -104,40 +591,32 @@ function migrate(database) {
104
591
  version INTEGER NOT NULL CHECK (version >= 1)
105
592
  ) STRICT;
106
593
  PRAGMA user_version = 4;
107
- COMMIT;
108
594
  `);
109
595
  version = 4;
110
596
  }
111
597
  if (version === 3) {
112
598
  database.exec(`
113
- BEGIN IMMEDIATE;
114
599
  ALTER TABLE conversation_model_selections ADD COLUMN reasoning_effort TEXT;
115
600
  PRAGMA user_version = 4;
116
- COMMIT;
117
601
  `);
118
602
  version = 4;
119
603
  }
120
604
  if (version === 4) {
121
605
  database.exec(`
122
- BEGIN IMMEDIATE;
123
606
  ${modelPickerStateSchema}
124
607
  PRAGMA user_version = 5;
125
- COMMIT;
126
608
  `);
127
609
  version = 5;
128
610
  }
129
611
  if (version === 5) {
130
612
  database.exec(`
131
- BEGIN IMMEDIATE;
132
613
  ${approvalDispatchCursorSchema}
133
614
  PRAGMA user_version = 6;
134
- COMMIT;
135
615
  `);
136
616
  version = 6;
137
617
  }
138
618
  if (version === 6) {
139
619
  database.exec(`
140
- BEGIN IMMEDIATE;
141
620
  CREATE TABLE delivery_attachments_v7 (
142
621
  id TEXT PRIMARY KEY,
143
622
  owner_kind TEXT NOT NULL CHECK (owner_kind IN ('inbox', 'outbox')),
@@ -172,21 +651,139 @@ function migrate(database) {
172
651
  CREATE UNIQUE INDEX delivery_attachment_owner_ordinal
173
652
  ON delivery_attachments(owner_kind, owner_id, ordinal);
174
653
  PRAGMA user_version = 7;
175
- COMMIT;
176
654
  `);
177
655
  version = 7;
178
656
  }
179
657
  if (version === 7) {
180
658
  database.exec(`
181
- BEGIN IMMEDIATE;
182
659
  ${deliveryInstanceSchema}
183
660
  PRAGMA user_version = 8;
184
- COMMIT;
185
661
  `);
186
- return;
662
+ version = 8;
663
+ }
664
+ if (version === 8) {
665
+ // v8 represented an operator cancel only by overwriting failure_code. A
666
+ // plain table creation would revive those terminal attempts as actionable
667
+ // and allow an old ambiguous send to be retried after upgrade. Preserve
668
+ // the lost decision as an immutable legacy receipt before publishing v9.
669
+ const hasInbox = hasTable(database, 'inbox_messages');
670
+ const hasOutbox = hasTable(database, 'outbox_messages');
671
+ const hasInboxAttempts = hasTable(database, 'inbox_attempts');
672
+ const hasOutboxAttempts = hasTable(database, 'outbox_attempts');
673
+ const inboxOriginalFailure = hasInboxAttempts ? `COALESCE((
674
+ SELECT attempt.failure_code FROM inbox_attempts AS attempt
675
+ WHERE attempt.inbox_id = message.id
676
+ AND attempt.attempt_number = message.attempt_count
677
+ AND attempt.failure_code IS NOT NULL
678
+ ORDER BY attempt.rowid DESC LIMIT 1
679
+ ), 'legacy-unknown')` : `'legacy-unknown'`;
680
+ const outboxOriginalFailure = hasOutboxAttempts ? `COALESCE((
681
+ SELECT attempt.failure_code FROM outbox_attempts AS attempt
682
+ WHERE attempt.outbox_id = message.id
683
+ AND attempt.attempt_number = message.attempt_count
684
+ AND attempt.failure_code IS NOT NULL
685
+ ORDER BY attempt.rowid DESC LIMIT 1
686
+ ), 'legacy-unknown')` : `'legacy-unknown'`;
687
+ const inboxBackfill = hasInbox ? `
688
+ INSERT INTO dead_letter_resolutions (
689
+ kind, message_id, attempt_count, receipt_version, resolution, original_status,
690
+ original_failure_code, operator_id, created_at
691
+ )
692
+ SELECT 'inbox', message.id, message.attempt_count, 1, 'cancel', 'dead_letter',
693
+ ${inboxOriginalFailure}, 'legacy-v8-migration', message.updated_at
694
+ FROM inbox_messages AS message
695
+ WHERE message.status = 'dead_letter' AND message.failure_code = 'operator-cancelled';
696
+ ` : '';
697
+ const outboxBackfill = hasOutbox ? `
698
+ INSERT INTO dead_letter_resolutions (
699
+ kind, message_id, attempt_count, receipt_version, resolution, original_status,
700
+ original_failure_code, operator_id, created_at
701
+ )
702
+ SELECT 'outbox', message.id, message.attempt_count, 1, 'cancel',
703
+ CASE WHEN ${hasOutboxAttempts ? `EXISTS (
704
+ SELECT 1 FROM outbox_attempts AS attempt
705
+ WHERE attempt.outbox_id = message.id
706
+ AND attempt.attempt_number = message.attempt_count
707
+ AND attempt.status = 'unknown_after_send'
708
+ )` : '0'} THEN 'unknown_after_send' ELSE 'dead' END,
709
+ ${outboxOriginalFailure}, 'legacy-v8-migration', message.updated_at
710
+ FROM outbox_messages AS message
711
+ WHERE message.status = 'dead' AND message.failure_code = 'operator-cancelled';
712
+
713
+ UPDATE outbox_messages AS message
714
+ SET status = 'unknown_after_send', failure_code = 'operator-cancelled-unknown'
715
+ WHERE message.status = 'dead' AND message.failure_code = 'operator-cancelled'
716
+ AND EXISTS (
717
+ SELECT 1 FROM dead_letter_resolutions AS resolution
718
+ WHERE resolution.kind = 'outbox' AND resolution.message_id = message.id
719
+ AND resolution.attempt_count = message.attempt_count
720
+ AND resolution.original_status = 'unknown_after_send'
721
+ );
722
+ ` : '';
723
+ database.exec(`
724
+ ${deadLetterResolutionSchema}
725
+ ${inboxBackfill}
726
+ ${outboxBackfill}
727
+ ${hasInbox && hasOutbox ? deadLetterResolutionCompatibilitySchema : ''}
728
+ PRAGMA user_version = 9;
729
+ `);
730
+ version = 9;
731
+ }
732
+ if (version === 9) {
733
+ database.exec(`
734
+ ${deliveryPresentationSchema}
735
+ PRAGMA user_version = 10;
736
+ `);
737
+ version = 10;
187
738
  }
739
+ if (version === 10) {
740
+ database.exec(`
741
+ ${workflowTraceSchema}
742
+ PRAGMA user_version = 11;
743
+ `);
744
+ version = 11;
745
+ }
746
+ if (version === 11) {
747
+ database.exec(`
748
+ ${preferenceProjectionSchema}
749
+ PRAGMA user_version = 12;
750
+ `);
751
+ version = 12;
752
+ }
753
+ if (version === 12) {
754
+ database.exec(hasTable(database, 'inbox_messages')
755
+ && hasTable(database, 'delivery_instance')
756
+ ? `
757
+ ${inboxAdmissionSchema}
758
+ PRAGMA user_version = 13;
759
+ `
760
+ : `
761
+ PRAGMA user_version = 13;
762
+ `);
763
+ version = 13;
764
+ }
765
+ if (version === 13) {
766
+ migratePreferenceProjectionLane(database);
767
+ version = 14;
768
+ }
769
+ if (version === 14) {
770
+ database.exec(`
771
+ ${workflowVerifiedTaskFeedbackSchema}
772
+ PRAGMA user_version = 15;
773
+ `);
774
+ version = 15;
775
+ }
776
+ if (version === 15) {
777
+ database.exec(`
778
+ ${approvalOutboxRouteSchema}
779
+ PRAGMA user_version = 16;
780
+ `);
781
+ assertApprovalOutboxRouteSchema(database);
782
+ version = 16;
783
+ }
784
+ if (version === deliverySchemaVersion)
785
+ return;
188
786
  database.exec(`
189
- BEGIN IMMEDIATE;
190
787
  ${deliveryInstanceSchema}
191
788
  CREATE TABLE delivery_principals (
192
789
  id TEXT PRIMARY KEY,
@@ -378,10 +975,41 @@ function migrate(database) {
378
975
 
379
976
  ${approvalDispatchCursorSchema}
380
977
 
381
- PRAGMA user_version = 8;
382
- COMMIT;
978
+ ${approvalOutboxRouteSchema}
979
+
980
+ ${deadLetterResolutionSchema}
981
+
982
+ ${deadLetterResolutionCompatibilitySchema}
983
+
984
+ ${deliveryPresentationSchema}
985
+
986
+ ${workflowTraceSchema}
987
+
988
+ ${workflowVerifiedTaskFeedbackSchema}
989
+
990
+ ${preferenceProjectionSchema}
991
+
992
+ ${inboxAdmissionSchema}
993
+
994
+ PRAGMA user_version = 16;
383
995
  `);
384
996
  }
997
+ /** Serialize the complete forward migration chain across every Host process. */
998
+ function migrate(database) {
999
+ database.exec('BEGIN IMMEDIATE');
1000
+ try {
1001
+ migrateObserved(database);
1002
+ assertApprovalOutboxRouteSchema(database);
1003
+ database.exec('COMMIT');
1004
+ }
1005
+ catch (error) {
1006
+ try {
1007
+ database.exec('ROLLBACK');
1008
+ }
1009
+ catch { }
1010
+ throw error;
1011
+ }
1012
+ }
385
1013
  export function openDeliveryDatabase(path) {
386
1014
  if (path !== ':memory:' && !isAbsolute(path)) {
387
1015
  throw new DeliveryDatabaseError('invalid-path', 'delivery database path must be absolute');