@effect-agent/storage-sqlite 0.1.0-beta.9 → 0.1.0-beta.90

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 (61) hide show
  1. package/dist/SqliteActivityStore.d.mts +14 -0
  2. package/dist/SqliteActivityStore.mjs +310 -0
  3. package/dist/SqliteActivityStore.mjs.map +1 -0
  4. package/dist/SqliteMessageDeliveryStore.d.mts +14 -0
  5. package/dist/SqliteMessageDeliveryStore.mjs +24 -0
  6. package/dist/SqliteMessageDeliveryStore.mjs.map +1 -0
  7. package/dist/SqliteScheduleStore.d.mts +14 -0
  8. package/dist/SqliteScheduleStore.mjs +255 -0
  9. package/dist/SqliteScheduleStore.mjs.map +1 -0
  10. package/dist/SqliteStorageConfig.d.mts +34 -0
  11. package/dist/SqliteStorageConfig.mjs +39 -0
  12. package/dist/SqliteStorageConfig.mjs.map +1 -0
  13. package/dist/SqliteStorageError-DVWeaWLm.d.mts +86 -0
  14. package/dist/SqliteStorageError.d.mts +2 -0
  15. package/dist/SqliteStorageError.mjs +150 -0
  16. package/dist/SqliteStorageError.mjs.map +1 -0
  17. package/dist/SqliteStorageFailpoint.d.mts +17 -0
  18. package/dist/SqliteStorageFailpoint.mjs +14 -0
  19. package/dist/SqliteStorageFailpoint.mjs.map +1 -0
  20. package/dist/SqliteStorageFailpointTesting.d.mts +15 -0
  21. package/dist/SqliteStorageFailpointTesting.mjs +19 -0
  22. package/dist/SqliteStorageFailpointTesting.mjs.map +1 -0
  23. package/dist/SqliteStorageVersion-BqmqX0CI.d.mts +11 -0
  24. package/dist/SqliteStorageVersion.d.mts +2 -0
  25. package/dist/SqliteStorageVersion.mjs +8 -0
  26. package/dist/SqliteStorageVersion.mjs.map +1 -0
  27. package/dist/SqliteSubmissionLedger.d.mts +23 -0
  28. package/dist/SqliteSubmissionLedger.mjs +1788 -0
  29. package/dist/SqliteSubmissionLedger.mjs.map +1 -0
  30. package/dist/SqliteSubscriptionStore.d.mts +13 -0
  31. package/dist/SqliteSubscriptionStore.mjs +28 -0
  32. package/dist/SqliteSubscriptionStore.mjs.map +1 -0
  33. package/dist/SqliteThreadStore.d.mts +49 -0
  34. package/dist/SqliteThreadStore.mjs +460 -0
  35. package/dist/SqliteThreadStore.mjs.map +1 -0
  36. package/dist/index.d.mts +11 -193
  37. package/dist/index.mjs +11 -3082
  38. package/dist/migrations-Dy5v0HGe.mjs +346 -0
  39. package/dist/migrations-Dy5v0HGe.mjs.map +1 -0
  40. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  41. package/dist/sqlite-journal-CBOQDySe.mjs +998 -0
  42. package/dist/sqlite-journal-CBOQDySe.mjs.map +1 -0
  43. package/package.json +1 -41
  44. package/src/SqliteActivityStore.ts +556 -0
  45. package/src/SqliteMessageDeliveryStore.ts +42 -0
  46. package/src/SqliteScheduleStore.ts +404 -0
  47. package/src/{sqlite-storage-config.ts → SqliteStorageConfig.ts} +1 -1
  48. package/src/{errors.ts → SqliteStorageError.ts} +10 -3
  49. package/src/SqliteStorageFailpoint.ts +23 -0
  50. package/src/{sqlite-storage-failpoint.ts → SqliteStorageFailpointTesting.ts} +7 -18
  51. package/src/SqliteStorageVersion.ts +2 -0
  52. package/src/{sqlite-ledger.ts → SqliteSubmissionLedger.ts} +912 -212
  53. package/src/SqliteSubscriptionStore.ts +55 -0
  54. package/src/SqliteThreadStore.ts +999 -0
  55. package/src/index.ts +10 -6
  56. package/src/internal/message-delivery-schema.ts +24 -0
  57. package/src/{migrations.ts → internal/migrations.ts} +156 -79
  58. package/src/internal/recovery-checkpoint-schema.ts +17 -0
  59. package/src/{sqlite-journal.ts → internal/sqlite-journal.ts} +751 -241
  60. package/dist/index.mjs.map +0 -1
  61. package/src/sqlite-conversation-store.ts +0 -841
@@ -1,18 +1,37 @@
1
+ import { NodeCrypto } from "@effect/platform-node";
2
+ import { SqliteClient } from "@effect/sql-sqlite-node";
3
+ import { Clock, Context, Crypto, DateTime, Effect, Layer, Option, Schema, Stream } from "effect";
4
+ import { EMPTY_TAIL_DIGEST } from "effect-agent/digest";
5
+ import { InputMessage } from "effect-agent/messaging";
6
+ import {
7
+ ApprovalDecision,
8
+ CanonicalSequence,
9
+ DefinitionDigests,
10
+ Digest,
11
+ PersistedJson,
12
+ WorkerAdmission,
13
+ ProducerEpoch,
14
+ RecordEnvelope,
15
+ SettlementOutcome,
16
+ } from "effect-agent/records";
1
17
  import {
2
18
  AbortCommand,
3
19
  AbortIntent,
20
+ AbortIntentRequest,
4
21
  AdmissionAdmitted,
5
22
  AdmissionConflict,
23
+ AdmissionFence,
24
+ AdmissionGroup,
25
+ AdmissionPolicyError,
6
26
  AdmissionNotAdmitted,
7
27
  AdmissionRequest,
28
+ SubmissionAdmissionFence,
8
29
  AdmissionResult,
9
30
  ApprovalConflict,
10
- ApprovalDecision,
11
31
  ApprovalDecisionCommand,
12
32
  ApprovalDecisionIntent,
13
33
  AttachChildToReservationRequest,
14
34
  BeginChildBudgetReleaseRequest,
15
- CanonicalSequence,
16
35
  ChildAttachmentSnapshot,
17
36
  ChildBudgetReservationRequest,
18
37
  ChildBudgetReservationSnapshot,
@@ -22,9 +41,6 @@ import {
22
41
  Claim,
23
42
  ClaimJoiningRequest,
24
43
  ClaimRequest,
25
- DefinitionDigests,
26
- Digest,
27
- EMPTY_TAIL_DIGEST,
28
44
  InputAppliedMarker,
29
45
  JoinSnapshot,
30
46
  JoinedToHost,
@@ -39,10 +55,7 @@ import {
39
55
  OwnershipRenewal,
40
56
  OwnershipSnapshot,
41
57
  ParentLinkage,
42
- PersistedJson,
43
- ProducerEpoch,
44
58
  QueueSequence,
45
- RecordEnvelope,
46
59
  RecoverySnapshot,
47
60
  RecoverySnapshotRequest,
48
61
  ReleaseChildBudgetRequest,
@@ -54,7 +67,6 @@ import {
54
67
  Settlement,
55
68
  SettlementConflict,
56
69
  SettlementFinalization,
57
- SettlementOutcome,
58
70
  SettlementReservation,
59
71
  SubmissionLedger,
60
72
  SubmissionLookup,
@@ -62,6 +74,7 @@ import {
62
74
  SubmissionSnapshot,
63
75
  SubmissionState,
64
76
  SettlementReservationSnapshot,
77
+ settlementFailureFromRecord,
65
78
  SuspendRequest,
66
79
  SuspensionReason,
67
80
  SuspensionSnapshot,
@@ -72,29 +85,26 @@ import {
72
85
  submissionAbortRecordId,
73
86
  type ChildSettledOutcome,
74
87
  type SuspensionOutcome,
75
- } from "@effect-agent/session";
76
- import { NodeCrypto } from "@effect/platform-node";
77
- import { SqliteClient } from "@effect/sql-sqlite-node";
78
- import { Clock, Context, Crypto, DateTime, Effect, Layer, Option, Schema, Stream } from "effect";
88
+ } from "effect-agent/submission-ledger";
79
89
  import * as SqlClientService from "effect/unstable/sql/SqlClient";
80
90
  import type { SqlError } from "effect/unstable/sql/SqlError";
81
91
 
92
+ import { decodeRows, initializeSqliteJournal } from "./internal/sqlite-journal.ts";
93
+ import { SqliteStorageConfig } from "./SqliteStorageConfig.ts";
82
94
  import {
83
95
  SqliteLedgerError,
84
96
  SqliteStorageCorruptionError,
85
97
  SqliteStorageError,
86
98
  SqliteWriteContention,
87
99
  type SqliteStorageFailpointLocation,
88
- } from "./errors.ts";
100
+ } from "./SqliteStorageError.ts";
101
+ import { SqliteStorageFailpoint } from "./SqliteStorageFailpoint.ts";
89
102
  import {
90
103
  storageConfigLayer,
91
104
  storageFailpointLayer,
92
105
  type SqliteStorageInitializationError,
93
106
  type SqliteStorageOptions,
94
- } from "./sqlite-conversation-store.ts";
95
- import { decodeRows, initializeSqliteJournal } from "./sqlite-journal.ts";
96
- import { SqliteStorageConfig } from "./sqlite-storage-config.ts";
97
- import { SqliteStorageFailpoint } from "./sqlite-storage-failpoint.ts";
107
+ } from "./SqliteThreadStore.ts";
98
108
 
99
109
  type SubmissionId = SubmissionSnapshot["submissionId"];
100
110
 
@@ -104,10 +114,15 @@ const BoundedTimestamp = Schema.NonEmptyString.check(Schema.isMaxLength(128));
104
114
 
105
115
  const SCAN_PAGE_SIZE = 256;
106
116
  const EPOCH_ZERO = Schema.decodeSync(ProducerEpoch)(0);
117
+ const RESUME_IMMEDIATELY: SuspensionOutcome = "resume-immediately";
118
+ const SUSPENDED: SuspensionOutcome = "suspended";
119
+ const NOT_WAITING: ChildSettledOutcome = "not-waiting";
120
+ const STILL_WAITING: ChildSettledOutcome = "still-waiting";
121
+ const WOKEN: ChildSettledOutcome = "woken";
107
122
 
108
123
  class SubmissionRow extends Schema.Class<SubmissionRow>("SubmissionRow")({
109
124
  submission_id: BoundedIdentifier,
110
- conversation_id: BoundedIdentifier,
125
+ thread_id: BoundedIdentifier,
111
126
  queue_sequence: QueueSequence,
112
127
  principal: BoundedIdentifier,
113
128
  idempotency_key: BoundedIdentifier,
@@ -130,6 +145,10 @@ class SubmissionRow extends Schema.Class<SubmissionRow>("SubmissionRow")({
130
145
  unknown_tool_call_ids_json: Schema.NullOr(BoundedStoredText),
131
146
  parent_submission_id: Schema.NullOr(BoundedIdentifier),
132
147
  parent_tool_call_id: Schema.NullOr(BoundedIdentifier),
148
+ admission_group: Schema.NullOr(AdmissionGroup),
149
+ admission_fence_json: Schema.NullOr(BoundedStoredText),
150
+ worker_admission_json: Schema.NullOr(BoundedStoredText),
151
+ message_admission_json: Schema.NullOr(BoundedStoredText),
133
152
  }) {}
134
153
 
135
154
  class ChildReservationRow extends Schema.Class<ChildReservationRow>("ChildReservationRow")({
@@ -196,13 +215,22 @@ class MaxQueueSequenceRow extends Schema.Class<MaxQueueSequenceRow>("MaxQueueSeq
196
215
  max_queue_sequence: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
197
216
  }) {}
198
217
 
218
+ class AbortIntentLookupRow extends Schema.Class<AbortIntentLookupRow>("AbortIntentLookupRow")({
219
+ submission_id: BoundedIdentifier,
220
+ abort_submission_id: Schema.NullOr(BoundedIdentifier),
221
+ author: Schema.NullOr(BoundedIdentifier),
222
+ reason: Schema.NullOr(BoundedStoredText),
223
+ requested_at: Schema.NullOr(BoundedTimestamp),
224
+ canonical_record_id: Schema.NullOr(BoundedIdentifier),
225
+ }) {}
226
+
199
227
  class CanonicalRecordIdRow extends Schema.Class<CanonicalRecordIdRow>("CanonicalRecordIdRow")({
200
228
  record_id: BoundedIdentifier,
201
229
  }) {}
202
230
 
203
231
  const SUBMISSION_COLUMNS = `
204
232
  submission_id,
205
- conversation_id,
233
+ thread_id,
206
234
  queue_sequence,
207
235
  principal,
208
236
  idempotency_key,
@@ -224,7 +252,11 @@ const SUBMISSION_COLUMNS = `
224
252
  unknown_reason,
225
253
  unknown_tool_call_ids_json,
226
254
  parent_submission_id,
227
- parent_tool_call_id
255
+ parent_tool_call_id,
256
+ admission_group,
257
+ admission_fence_json,
258
+ worker_admission_json,
259
+ message_admission_json
228
260
  `;
229
261
 
230
262
  const CHILD_RESERVATION_COLUMNS = `
@@ -241,7 +273,7 @@ const CHILD_RESERVATION_COLUMNS = `
241
273
  released_at
242
274
  `;
243
275
 
244
- /** The branded ToolCallId schema, reached through the session port so no core import is needed. */
276
+ /** The branded ToolCallId schema, reached through the thread port so no core import is needed. */
245
277
  const ToolCallIdSchema = ApprovalDecisionCommand.fields.toolCallId;
246
278
  const ToolCallIdList = Schema.Array(ToolCallIdSchema);
247
279
 
@@ -271,10 +303,18 @@ const decodeSuspensionSnapshot = Schema.decodeUnknownEffect(SuspensionSnapshot);
271
303
  const decodeApprovalDecisionIntent = Schema.decodeUnknownEffect(ApprovalDecisionIntent);
272
304
  const decodeUnknownResolutionIntent = Schema.decodeUnknownEffect(UnknownResolutionIntent);
273
305
  const decodeParentLinkage = Schema.decodeUnknownEffect(ParentLinkage);
306
+
274
307
  const decodeChildReservationSnapshotUnknown = Schema.decodeUnknownEffect(
275
308
  ChildBudgetReservationSnapshot,
276
309
  );
310
+
277
311
  const decodeChildAttachmentSnapshot = Schema.decodeUnknownEffect(ChildAttachmentSnapshot);
312
+ const equivalentPersistedJson = Schema.toEquivalence(PersistedJson);
313
+ const equivalentUnknownResolution = Schema.toEquivalence(UnknownResolution);
314
+
315
+ const isSqliteTransactionFailure = Schema.is(
316
+ Schema.Union([SqliteStorageError, SqliteWriteContention]),
317
+ );
278
318
 
279
319
  /** Wrap an adapter-internal failure into the port's LedgerError without erasing its tag. */
280
320
  const internalFailure =
@@ -298,6 +338,7 @@ const sqlFailure =
298
338
  operation,
299
339
  message: error.message,
300
340
  });
341
+
301
342
  return internalFailure(operation)(internal);
302
343
  };
303
344
 
@@ -309,7 +350,8 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
309
350
  const failpoint = yield* SqliteStorageFailpoint;
310
351
  const sql = yield* SqlClientService.SqlClient;
311
352
  const crypto = yield* Crypto.Crypto;
312
- const journal = yield* initializeSqliteJournal(sql, failpoint.hit, config.busyTimeout);
353
+ const admissionFence = yield* SubmissionAdmissionFence;
354
+ const journal = yield* initializeSqliteJournal();
313
355
 
314
356
  const hitFailpoint = (
315
357
  location: SqliteStorageFailpointLocation,
@@ -327,6 +369,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
327
369
  A,
328
370
  E extends
329
371
  | AdmissionConflict
372
+ | AdmissionPolicyError
330
373
  | ApprovalConflict
331
374
  | ChildReservationConflict
332
375
  | JoinedToHost
@@ -342,9 +385,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
342
385
  .withWriteTransaction(operation)(effect)
343
386
  .pipe(
344
387
  Effect.mapError((error) =>
345
- error instanceof SqliteStorageError || error instanceof SqliteWriteContention
346
- ? internalFailure(operation)(error)
347
- : error,
388
+ isSqliteTransactionFailure(error) ? internalFailure(operation)(error) : error,
348
389
  ),
349
390
  );
350
391
 
@@ -381,7 +422,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
381
422
  FROM effect_agent_submissions
382
423
  WHERE submission_id = ${submissionId}
383
424
  `.pipe(Effect.mapError(sqlFailure(operation)));
425
+
384
426
  const decoded = yield* decodeSubmissionRows(operation, submissionId, rows);
427
+
385
428
  if (decoded.length > 1) {
386
429
  return yield* corruptionFailure(
387
430
  operation,
@@ -390,6 +433,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
390
433
  "A submission primary key returned more than one row.",
391
434
  );
392
435
  }
436
+
393
437
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
394
438
  });
395
439
 
@@ -398,12 +442,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
398
442
  submissionId: string,
399
443
  ): Effect.fn.Return<SubmissionRow, LedgerError> {
400
444
  const submission = yield* readSubmission(operation, submissionId);
445
+
401
446
  if (Option.isNone(submission)) {
402
447
  return yield* LedgerError.make({
403
448
  operation,
404
449
  message: `Unknown submission ${submissionId}.`,
405
450
  });
406
451
  }
452
+
407
453
  return submission.value;
408
454
  });
409
455
 
@@ -422,12 +468,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
422
468
  FROM effect_agent_submission_ownership
423
469
  WHERE submission_id = ${submissionId}
424
470
  `.pipe(Effect.mapError(sqlFailure(operation)));
471
+
425
472
  const decoded = yield* decodeRows(
426
473
  Schema.Array(OwnershipRow),
427
474
  "effect_agent_submission_ownership",
428
475
  submissionId,
429
476
  rows,
430
477
  ).pipe(Effect.mapError(internalFailure(operation)));
478
+
431
479
  if (decoded.length > 1) {
432
480
  return yield* corruptionFailure(
433
481
  operation,
@@ -436,23 +484,25 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
436
484
  "An ownership primary key returned more than one row.",
437
485
  );
438
486
  }
487
+
439
488
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
440
489
  });
441
490
 
442
- const conversationEpoch = Effect.fn("SqliteSubmissionLedger.conversationEpoch")(function* (
491
+ const threadEpoch = Effect.fn("SqliteSubmissionLedger.threadEpoch")(function* (
443
492
  operation: string,
444
- conversationId: string,
493
+ threadId: string,
445
494
  ): Effect.fn.Return<ProducerEpoch, LedgerError> {
446
- const conversations = yield* journal
447
- .getConversation(conversationId)
495
+ const threads = yield* journal
496
+ .getThread(threadId)
448
497
  .pipe(Effect.mapError(internalFailure(operation)));
449
- return conversations.length === 0 ? EPOCH_ZERO : conversations[0].producer_epoch;
498
+
499
+ return threads.length === 0 ? EPOCH_ZERO : threads[0].producer_epoch;
450
500
  });
451
501
 
452
502
  /**
453
503
  * Verify inside the surrounding write transaction that the presented token still owns the
454
504
  * Submission's lane; a superseded or missing token fails with OwnershipLost carrying the
455
- * Conversation's current producer epoch (DUR-006).
505
+ * Thread's current producer epoch (DUR-006).
456
506
  */
457
507
  const requireOwnership = Effect.fn("SqliteSubmissionLedger.requireOwnership")(function* (
458
508
  operation: string,
@@ -460,13 +510,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
460
510
  ownershipToken: string,
461
511
  ): Effect.fn.Return<OwnershipRow, OwnershipLost | LedgerError> {
462
512
  const ownership = yield* readOwnership(operation, submission.submission_id);
513
+
463
514
  if (Option.isNone(ownership) || ownership.value.ownership_token !== ownershipToken) {
464
- const actualEpoch = yield* conversationEpoch(operation, submission.conversation_id);
465
- const submissionId = yield* Schema.decodeUnknownEffect(
466
- SubmissionSnapshot.fields.submissionId,
467
- )(submission.submission_id).pipe(Effect.mapError(internalFailure(operation)));
515
+ const actualEpoch = yield* threadEpoch(operation, submission.thread_id);
516
+
517
+ const submissionId = yield* Schema.decodeEffect(SubmissionSnapshot.fields.submissionId)(
518
+ submission.submission_id,
519
+ ).pipe(Effect.mapError(internalFailure(operation)));
520
+
468
521
  return yield* OwnershipLost.make({ submissionId, actualEpoch });
469
522
  }
523
+
470
524
  return ownership.value;
471
525
  });
472
526
 
@@ -485,6 +539,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
485
539
  ),
486
540
  ),
487
541
  );
542
+
488
543
  const inputPayload = yield* parseStoredJsonText(row.input_json).pipe(
489
544
  Effect.mapError((error) =>
490
545
  corruptionFailure(
@@ -495,6 +550,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
495
550
  ),
496
551
  ),
497
552
  );
553
+
498
554
  if ((row.parent_submission_id === null) !== (row.parent_tool_call_id === null)) {
499
555
  return yield* corruptionFailure(
500
556
  operation,
@@ -503,9 +559,10 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
503
559
  "A parent linkage must record both the parent Submission and the parent Tool Call.",
504
560
  );
505
561
  }
562
+
506
563
  return yield* decodeSubmissionSnapshotUnknown({
507
564
  submissionId: row.submission_id,
508
- conversationId: row.conversation_id,
565
+ threadId: row.thread_id,
509
566
  queueSequence: row.queue_sequence,
510
567
  principal: row.principal,
511
568
  idempotencyKey: row.idempotency_key,
@@ -517,6 +574,28 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
517
574
  receiptId: row.receipt_id,
518
575
  state: row.state,
519
576
  createdAt: row.created_at,
577
+ ...(row.admission_group === null ? {} : { admissionGroup: row.admission_group }),
578
+ ...(row.worker_admission_json === null
579
+ ? {}
580
+ : {
581
+ workerAdmission: yield* parseStoredJsonText(row.worker_admission_json).pipe(
582
+ Effect.mapError(internalFailure(operation)),
583
+ ),
584
+ }),
585
+ ...(row.message_admission_json === null
586
+ ? {}
587
+ : {
588
+ messageAdmission: yield* parseStoredJsonText(row.message_admission_json).pipe(
589
+ Effect.mapError(internalFailure(operation)),
590
+ ),
591
+ }),
592
+ ...(row.admission_fence_json === null
593
+ ? {}
594
+ : {
595
+ admissionFence: yield* parseStoredJsonText(row.admission_fence_json).pipe(
596
+ Effect.mapError(internalFailure(operation)),
597
+ ),
598
+ }),
520
599
  ...(row.settled_outcome === null ? {} : { settledOutcome: row.settled_outcome }),
521
600
  ...(row.ready_at === null ? {} : { readyAt: row.ready_at }),
522
601
  ...(row.parent_submission_id === null || row.parent_tool_call_id === null
@@ -557,12 +636,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
557
636
  FROM effect_agent_settlement_reservations
558
637
  WHERE submission_id = ${submissionId}
559
638
  `.pipe(Effect.mapError(sqlFailure(operation)));
639
+
560
640
  const decoded = yield* decodeRows(
561
641
  Schema.Array(ReservationRow),
562
642
  "effect_agent_settlement_reservations",
563
643
  submissionId,
564
644
  rows,
565
645
  ).pipe(Effect.mapError(internalFailure(operation)));
646
+
566
647
  if (decoded.length > 1) {
567
648
  return yield* corruptionFailure(
568
649
  operation,
@@ -571,6 +652,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
571
652
  "A settlement reservation primary key returned more than one row.",
572
653
  );
573
654
  }
655
+
574
656
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
575
657
  });
576
658
 
@@ -588,12 +670,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
588
670
  FROM effect_agent_abort_intents
589
671
  WHERE submission_id = ${submissionId}
590
672
  `.pipe(Effect.mapError(sqlFailure(operation)));
673
+
591
674
  const decoded = yield* decodeRows(
592
675
  Schema.Array(AbortIntentRow),
593
676
  "effect_agent_abort_intents",
594
677
  submissionId,
595
678
  rows,
596
679
  ).pipe(Effect.mapError(internalFailure(operation)));
680
+
597
681
  if (decoded.length > 1) {
598
682
  return yield* corruptionFailure(
599
683
  operation,
@@ -602,6 +686,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
602
686
  "An abort intent primary key returned more than one row.",
603
687
  );
604
688
  }
689
+
605
690
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
606
691
  });
607
692
 
@@ -622,7 +707,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
622
707
  FROM effect_agent_child_reservations
623
708
  WHERE reservation_id = ${reservationId}
624
709
  `.pipe(Effect.mapError(sqlFailure(operation)));
710
+
625
711
  const decoded = yield* decodeChildReservationRows(operation, reservationId, rows);
712
+
626
713
  if (decoded.length > 1) {
627
714
  return yield* corruptionFailure(
628
715
  operation,
@@ -631,6 +718,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
631
718
  "A child reservation primary key returned more than one row.",
632
719
  );
633
720
  }
721
+
634
722
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
635
723
  });
636
724
 
@@ -647,11 +735,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
647
735
  WHERE parent_submission_id = ${parentSubmissionId}
648
736
  AND parent_tool_call_id = ${parentToolCallId}
649
737
  `.pipe(Effect.mapError(sqlFailure(operation)));
738
+
650
739
  const decoded = yield* decodeChildReservationRows(
651
740
  operation,
652
741
  `${parentSubmissionId}/${parentToolCallId}`,
653
742
  rows,
654
743
  );
744
+
655
745
  if (decoded.length > 1) {
656
746
  return yield* corruptionFailure(
657
747
  operation,
@@ -660,6 +750,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
660
750
  "A parent Tool Call returned more than one child reservation.",
661
751
  );
662
752
  }
753
+
663
754
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
664
755
  });
665
756
 
@@ -676,13 +767,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
676
767
  row.reservation_id,
677
768
  error.message,
678
769
  );
770
+
679
771
  const allocation = yield* parseStoredJsonText(row.allocation_json).pipe(
680
772
  Effect.mapError(rowFailure),
681
773
  );
774
+
682
775
  const accounting =
683
776
  row.accounting_json === null
684
777
  ? undefined
685
778
  : yield* parseStoredJsonText(row.accounting_json).pipe(Effect.mapError(rowFailure));
779
+
686
780
  return yield* decodeChildReservationSnapshotUnknown({
687
781
  reservationId: row.reservation_id,
688
782
  parentSubmissionId: row.parent_submission_id,
@@ -715,6 +809,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
715
809
  WHERE submission_id = ${submissionId}
716
810
  ORDER BY tool_call_id ASC
717
811
  `.pipe(Effect.mapError(sqlFailure(operation)));
812
+
718
813
  return yield* decodeRows(
719
814
  Schema.Array(ApprovalDecisionRow),
720
815
  "effect_agent_approval_decisions",
@@ -766,6 +861,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
766
861
  WHERE submission_id = ${submissionId}
767
862
  ORDER BY tool_call_id ASC
768
863
  `.pipe(Effect.mapError(sqlFailure(operation)));
864
+
769
865
  return yield* decodeRows(
770
866
  Schema.Array(UnknownResolutionRow),
771
867
  "effect_agent_unknown_resolutions",
@@ -791,6 +887,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
791
887
  ),
792
888
  ),
793
889
  );
890
+
794
891
  return yield* decodeUnknownResolutionIntent({
795
892
  submissionId: row.submission_id,
796
893
  toolCallId: row.tool_call_id,
@@ -817,6 +914,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
817
914
  submission: SubmissionRow,
818
915
  ): Effect.fn.Return<ReadonlyArray<typeof ToolCallIdSchema.Type>, LedgerError> {
819
916
  if (submission.unknown_tool_call_ids_json === null) return [];
917
+
820
918
  return yield* decodeToolCallIdsText(submission.unknown_tool_call_ids_json).pipe(
821
919
  Effect.mapError((error) =>
822
920
  corruptionFailure(
@@ -838,22 +936,25 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
838
936
  const canonicalAbortRecordId = Effect.fn("SqliteSubmissionLedger.canonicalAbortRecordId")(
839
937
  function* (
840
938
  operation: string,
841
- conversationId: string,
939
+ threadId: string,
842
940
  submissionId: SubmissionId,
843
941
  ): Effect.fn.Return<string | undefined, LedgerError> {
844
942
  const recordId = submissionAbortRecordId(submissionId);
943
+
845
944
  const rows = yield* sql<Record<string, unknown>>`
846
945
  SELECT record_id
847
946
  FROM effect_agent_canonical_records
848
- WHERE conversation_id = ${conversationId}
947
+ WHERE thread_id = ${threadId}
849
948
  AND record_id = ${recordId}
850
949
  `.pipe(Effect.mapError(sqlFailure(operation)));
950
+
851
951
  const decoded = yield* decodeRows(
852
952
  Schema.Array(CanonicalRecordIdRow),
853
953
  "effect_agent_canonical_records",
854
- `${conversationId}/${recordId}`,
954
+ `${threadId}/${recordId}`,
855
955
  rows,
856
956
  ).pipe(Effect.mapError(internalFailure(operation)));
957
+
857
958
  return decoded.length === 0 ? undefined : recordId;
858
959
  },
859
960
  );
@@ -866,9 +967,10 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
866
967
  ): Effect.fn.Return<AbortIntent, LedgerError> {
867
968
  const canonicalRecordId = yield* canonicalAbortRecordId(
868
969
  operation,
869
- submission.conversation_id,
970
+ submission.thread_id,
870
971
  submissionId,
871
972
  );
973
+
872
974
  return yield* decodeAbortIntent({
873
975
  submissionId: row.submission_id,
874
976
  author: row.author,
@@ -892,30 +994,73 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
892
994
  const admit: SubmissionLedger["Service"]["admit"] = Effect.fn("SqliteSubmissionLedger.admit")(
893
995
  function* (request: AdmissionRequest) {
894
996
  const operation = "ledger admit";
895
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(AdmissionRequest))(
896
- request,
897
- ).pipe(Effect.mapError(internalFailure(operation)));
997
+
998
+ const validated = yield* Schema.decodeEffect(Schema.toType(AdmissionRequest))(request).pipe(
999
+ Effect.mapError(internalFailure(operation)),
1000
+ );
1001
+
898
1002
  const inputJson = yield* encodePersistedJsonText(validated.inputPayload).pipe(
899
1003
  Effect.mapError(internalFailure(operation)),
900
1004
  );
1005
+
1006
+ const workerAdmissionJson =
1007
+ validated.workerAdmission === undefined
1008
+ ? null
1009
+ : yield* Schema.encodeEffect(Schema.fromJsonString(WorkerAdmission))(
1010
+ validated.workerAdmission,
1011
+ ).pipe(Effect.mapError(internalFailure(operation)));
1012
+
1013
+ if (
1014
+ workerAdmissionJson !== null &&
1015
+ new TextEncoder().encode(workerAdmissionJson).byteLength > 16 * 1024 * 1024
1016
+ ) {
1017
+ return yield* LedgerError.make({
1018
+ operation,
1019
+ message: "Worker admission metadata exceeds the stored value bound",
1020
+ });
1021
+ }
1022
+
1023
+ const messageAdmissionJson =
1024
+ validated.messageAdmission === undefined
1025
+ ? null
1026
+ : yield* Schema.encodeEffect(Schema.fromJsonString(InputMessage))(
1027
+ validated.messageAdmission,
1028
+ ).pipe(Effect.mapError(internalFailure(operation)));
1029
+
1030
+ if (
1031
+ messageAdmissionJson !== null &&
1032
+ new TextEncoder().encode(messageAdmissionJson).byteLength > 16 * 1024 * 1024
1033
+ ) {
1034
+ return yield* LedgerError.make({
1035
+ operation,
1036
+ message: "Message admission metadata exceeds the stored value bound",
1037
+ });
1038
+ }
1039
+
901
1040
  const agentDigestsJson = yield* encodeDefinitionDigestsText(validated.agentDigests).pipe(
902
1041
  Effect.mapError(internalFailure(operation)),
903
1042
  );
1043
+
904
1044
  const mintedSubmissionId = yield* mintIdentifier("submission", operation);
905
1045
  const mintedReceiptId = yield* mintIdentifier("receipt", operation);
1046
+
906
1047
  yield* hitFailpoint("ledger:admit:before", operation);
1048
+
907
1049
  const result = yield* inWriteTransaction(
908
1050
  operation,
909
1051
  Effect.gen(function* () {
910
- const keyRowKey = `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`;
1052
+ const keyRowKey = `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`;
1053
+
911
1054
  const existingRows = yield* sql<Record<string, unknown>>`
912
1055
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
913
1056
  FROM effect_agent_submissions
914
- WHERE conversation_id = ${validated.conversationId}
1057
+ WHERE thread_id = ${validated.threadId}
915
1058
  AND principal = ${validated.principal}
916
1059
  AND idempotency_key = ${validated.idempotencyKey}
917
1060
  `.pipe(Effect.mapError(sqlFailure(operation)));
1061
+
918
1062
  const existing = yield* decodeSubmissionRows(operation, keyRowKey, existingRows);
1063
+
919
1064
  if (existing.length > 1) {
920
1065
  return yield* corruptionFailure(
921
1066
  operation,
@@ -933,15 +1078,61 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
933
1078
  existing[0].parent_tool_call_id === null
934
1079
  : existing[0].parent_submission_id === validated.parentLinkage.parentSubmissionId &&
935
1080
  existing[0].parent_tool_call_id === validated.parentLinkage.parentToolCallId;
1081
+
936
1082
  if (existing[0].input_digest !== validated.inputDigest || !sameLinkage) {
937
1083
  return yield* AdmissionConflict.make({
938
- conversationId: validated.conversationId,
1084
+ threadId: validated.threadId,
939
1085
  principal: validated.principal,
940
1086
  idempotencyKey: validated.idempotencyKey,
941
1087
  existingInputDigest: existing[0].input_digest,
942
1088
  attemptedInputDigest: validated.inputDigest,
943
1089
  });
944
1090
  }
1091
+
1092
+ const retainedWorkerAdmission =
1093
+ existing[0].worker_admission_json === null
1094
+ ? undefined
1095
+ : yield* Schema.decodeEffect(Schema.fromJsonString(WorkerAdmission))(
1096
+ existing[0].worker_admission_json,
1097
+ ).pipe(Effect.mapError(internalFailure(operation)));
1098
+
1099
+ const retainedInputMessage =
1100
+ existing[0].message_admission_json === null
1101
+ ? undefined
1102
+ : yield* Schema.decodeEffect(Schema.fromJsonString(InputMessage))(
1103
+ existing[0].message_admission_json,
1104
+ ).pipe(Effect.mapError(internalFailure(operation)));
1105
+
1106
+ const retainedFence =
1107
+ existing[0].admission_fence_json === null
1108
+ ? undefined
1109
+ : yield* Schema.decodeEffect(Schema.fromJsonString(AdmissionFence))(
1110
+ existing[0].admission_fence_json,
1111
+ ).pipe(Effect.mapError(internalFailure(operation)));
1112
+
1113
+ if (
1114
+ (existing[0].admission_group ?? undefined) !== validated.admissionGroup ||
1115
+ !Schema.toEquivalence(Schema.optional(WorkerAdmission))(
1116
+ retainedWorkerAdmission,
1117
+ validated.workerAdmission,
1118
+ ) ||
1119
+ !Schema.toEquivalence(Schema.optional(InputMessage))(
1120
+ retainedInputMessage,
1121
+ validated.messageAdmission,
1122
+ ) ||
1123
+ !Schema.toEquivalence(Schema.optional(AdmissionFence))(
1124
+ retainedFence,
1125
+ validated.admissionFence,
1126
+ )
1127
+ )
1128
+ return yield* AdmissionConflict.make({
1129
+ threadId: validated.threadId,
1130
+ principal: validated.principal,
1131
+ idempotencyKey: validated.idempotencyKey,
1132
+ existingInputDigest: existing[0].input_digest,
1133
+ attemptedInputDigest: validated.inputDigest,
1134
+ });
1135
+
945
1136
  return yield* decodeAdmissionResult({
946
1137
  submissionId: existing[0].submission_id,
947
1138
  receiptId: existing[0].receipt_id,
@@ -951,26 +1142,78 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
951
1142
  }).pipe(Effect.mapError(internalFailure(operation)));
952
1143
  }
953
1144
 
1145
+ // The first accepted input fixes ordinary/worker lane identity atomically with admission.
1146
+ // Canonical origin materialization can lag admission; a log scan cannot fence that race.
1147
+ const firstRows = yield* sql<Record<string, unknown>>`
1148
+ SELECT worker_admission_json FROM effect_agent_submissions
1149
+ WHERE thread_id=${validated.threadId} ORDER BY queue_sequence LIMIT 1
1150
+ `.pipe(Effect.mapError(sqlFailure(operation)));
1151
+
1152
+ const first = yield* Schema.decodeUnknownEffect(
1153
+ Schema.Array(
1154
+ Schema.Struct({
1155
+ worker_admission_json: Schema.NullOr(BoundedStoredText),
1156
+ }),
1157
+ ),
1158
+ )(firstRows).pipe(Effect.mapError(internalFailure(operation)));
1159
+
1160
+ if (first[0] !== undefined) {
1161
+ const previous =
1162
+ first[0].worker_admission_json === null
1163
+ ? undefined
1164
+ : yield* Schema.decodeEffect(Schema.fromJsonString(WorkerAdmission))(
1165
+ first[0].worker_admission_json,
1166
+ ).pipe(Effect.mapError(internalFailure(operation)));
1167
+
1168
+ if (
1169
+ !Schema.toEquivalence(Schema.optional(WorkerAdmission.fields.origin))(
1170
+ previous?.origin,
1171
+ validated.workerAdmission?.origin,
1172
+ )
1173
+ )
1174
+ return yield* AdmissionPolicyError.make({
1175
+ reason: "refused",
1176
+ code: "worker-origin-conflict",
1177
+ });
1178
+ }
1179
+
1180
+ yield* admissionFence.check(validated);
1181
+ if (validated.admissionGroup !== undefined) {
1182
+ const occupied = yield* sql<Record<string, unknown>>`
1183
+ SELECT submission_id FROM effect_agent_submissions
1184
+ WHERE thread_id=${validated.threadId} AND admission_group=${validated.admissionGroup} AND state<>'settled' LIMIT 1
1185
+ `.pipe(Effect.mapError(sqlFailure(operation)));
1186
+
1187
+ if (occupied.length > 0)
1188
+ return yield* AdmissionPolicyError.make({
1189
+ reason: "occupied",
1190
+ code: "admission-group",
1191
+ });
1192
+ }
1193
+
954
1194
  const maxRows = yield* sql<Record<string, unknown>>`
955
1195
  SELECT COALESCE(MAX(queue_sequence), 0) AS max_queue_sequence
956
1196
  FROM effect_agent_submissions
957
- WHERE conversation_id = ${validated.conversationId}
1197
+ WHERE thread_id = ${validated.threadId}
958
1198
  `.pipe(Effect.mapError(sqlFailure(operation)));
1199
+
959
1200
  const decodedMax = yield* decodeRows(
960
1201
  Schema.Array(MaxQueueSequenceRow),
961
1202
  "effect_agent_submissions",
962
- validated.conversationId,
1203
+ validated.threadId,
963
1204
  maxRows,
964
1205
  ).pipe(Effect.mapError(internalFailure(operation)));
1206
+
965
1207
  const queueSequence = yield* decodeQueueSequence(
966
1208
  (decodedMax[0]?.max_queue_sequence ?? 0) + 1,
967
1209
  ).pipe(Effect.mapError(internalFailure(operation)));
1210
+
968
1211
  const now = yield* currentInstant;
969
1212
 
970
1213
  yield* sql`
971
1214
  INSERT INTO effect_agent_submissions (
972
1215
  submission_id,
973
- conversation_id,
1216
+ thread_id,
974
1217
  queue_sequence,
975
1218
  principal,
976
1219
  idempotency_key,
@@ -983,10 +1226,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
983
1226
  state,
984
1227
  created_at,
985
1228
  parent_submission_id,
986
- parent_tool_call_id
1229
+ parent_tool_call_id,
1230
+ admission_group,
1231
+ admission_fence_json,
1232
+ worker_admission_json,
1233
+ message_admission_json
987
1234
  ) VALUES (
988
1235
  ${mintedSubmissionId},
989
- ${validated.conversationId},
1236
+ ${validated.threadId},
990
1237
  ${queueSequence},
991
1238
  ${validated.principal},
992
1239
  ${validated.idempotencyKey},
@@ -999,7 +1246,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
999
1246
  'admitted',
1000
1247
  ${now.iso},
1001
1248
  ${validated.parentLinkage?.parentSubmissionId ?? null},
1002
- ${validated.parentLinkage?.parentToolCallId ?? null}
1249
+ ${validated.parentLinkage?.parentToolCallId ?? null},
1250
+ ${validated.admissionGroup ?? null},
1251
+ ${validated.admissionFence === undefined ? null : JSON.stringify(validated.admissionFence)},
1252
+ ${workerAdmissionJson},
1253
+ ${messageAdmissionJson}
1003
1254
  )
1004
1255
  `.pipe(Effect.mapError(sqlFailure(operation)));
1005
1256
 
@@ -1012,7 +1263,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1012
1263
  }).pipe(Effect.mapError(internalFailure(operation)));
1013
1264
  }),
1014
1265
  );
1266
+
1015
1267
  yield* hitFailpoint("ledger:admit:after", operation);
1268
+
1016
1269
  return result;
1017
1270
  },
1018
1271
  );
@@ -1021,16 +1274,20 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1021
1274
  "SqliteSubmissionLedger.markReady",
1022
1275
  )(function* (request: MarkReadyRequest) {
1023
1276
  const operation = "ledger mark ready";
1024
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(MarkReadyRequest))(
1025
- request,
1026
- ).pipe(Effect.mapError(internalFailure(operation)));
1277
+
1278
+ const validated = yield* Schema.decodeEffect(Schema.toType(MarkReadyRequest))(request).pipe(
1279
+ Effect.mapError(internalFailure(operation)),
1280
+ );
1281
+
1027
1282
  yield* hitFailpoint("ledger:mark-ready:before", operation);
1028
1283
  yield* inWriteTransaction(
1029
1284
  operation,
1030
1285
  Effect.gen(function* () {
1031
1286
  const submission = yield* requireSubmission(operation, validated.submissionId);
1287
+
1032
1288
  if (submission.state !== "admitted") return;
1033
1289
  const now = yield* currentInstant;
1290
+
1034
1291
  yield* sql`
1035
1292
  UPDATE effect_agent_submissions
1036
1293
  SET state = 'ready', ready_at = ${now.iso}
@@ -1044,35 +1301,43 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1044
1301
  const lookup: SubmissionLedger["Service"]["lookup"] = Effect.fn("SqliteSubmissionLedger.lookup")(
1045
1302
  function* (request: SubmissionLookup) {
1046
1303
  const operation = "ledger lookup";
1047
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SubmissionLookup))(
1048
- request,
1049
- ).pipe(Effect.mapError(internalFailure(operation)));
1304
+
1305
+ const validated = yield* Schema.decodeEffect(Schema.toType(SubmissionLookup))(request).pipe(
1306
+ Effect.mapError(internalFailure(operation)),
1307
+ );
1308
+
1050
1309
  if (validated._tag === "SubmissionLookupById") {
1051
1310
  const row = yield* readSubmission(operation, validated.submissionId);
1311
+
1052
1312
  if (Option.isNone(row)) return Option.none();
1313
+
1053
1314
  return Option.some(yield* decodeSubmissionSnapshot(operation, row.value));
1054
1315
  }
1316
+
1055
1317
  const rows = yield* sql<Record<string, unknown>>`
1056
1318
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
1057
1319
  FROM effect_agent_submissions
1058
- WHERE conversation_id = ${validated.conversationId}
1320
+ WHERE thread_id = ${validated.threadId}
1059
1321
  AND principal = ${validated.principal}
1060
1322
  AND idempotency_key = ${validated.idempotencyKey}
1061
1323
  `.pipe(Effect.mapError(sqlFailure(operation)));
1324
+
1062
1325
  const decoded = yield* decodeSubmissionRows(
1063
1326
  operation,
1064
- `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`,
1327
+ `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
1065
1328
  rows,
1066
1329
  );
1330
+
1067
1331
  if (decoded.length > 1) {
1068
1332
  return yield* corruptionFailure(
1069
1333
  operation,
1070
1334
  "effect_agent_submissions",
1071
- `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`,
1335
+ `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
1072
1336
  "An admission idempotency key returned more than one row.",
1073
1337
  );
1074
1338
  }
1075
1339
  if (decoded.length === 0) return Option.none();
1340
+
1076
1341
  return Option.some(yield* decodeSubmissionSnapshot(operation, decoded[0]));
1077
1342
  },
1078
1343
  );
@@ -1084,30 +1349,35 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1084
1349
  "SqliteSubmissionLedger.resolveAdmission",
1085
1350
  )(function* (request: SubmissionLookupByKey) {
1086
1351
  const operation = "ledger resolve admission";
1087
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SubmissionLookupByKey))(
1352
+
1353
+ const validated = yield* Schema.decodeEffect(Schema.toType(SubmissionLookupByKey))(
1088
1354
  request,
1089
1355
  ).pipe(Effect.mapError(internalFailure(operation)));
1356
+
1090
1357
  const rows = yield* sql<Record<string, unknown>>`
1091
1358
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
1092
1359
  FROM effect_agent_submissions
1093
- WHERE conversation_id = ${validated.conversationId}
1360
+ WHERE thread_id = ${validated.threadId}
1094
1361
  AND principal = ${validated.principal}
1095
1362
  AND idempotency_key = ${validated.idempotencyKey}
1096
1363
  `.pipe(Effect.mapError(sqlFailure(operation)));
1364
+
1097
1365
  const decoded = yield* decodeSubmissionRows(
1098
1366
  operation,
1099
- `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`,
1367
+ `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
1100
1368
  rows,
1101
1369
  );
1370
+
1102
1371
  if (decoded.length > 1) {
1103
1372
  return yield* corruptionFailure(
1104
1373
  operation,
1105
1374
  "effect_agent_submissions",
1106
- `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`,
1375
+ `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
1107
1376
  "An admission idempotency key returned more than one row.",
1108
1377
  );
1109
1378
  }
1110
1379
  if (decoded.length === 0) return AdmissionNotAdmitted.make();
1380
+
1111
1381
  return AdmissionAdmitted.make({
1112
1382
  submission: yield* decodeSubmissionSnapshot(operation, decoded[0]),
1113
1383
  });
@@ -1116,70 +1386,81 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1116
1386
  const claim: SubmissionLedger["Service"]["claim"] = Effect.fn("SqliteSubmissionLedger.claim")(
1117
1387
  function* (request: ClaimRequest) {
1118
1388
  const operation = "ledger claim";
1119
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ClaimRequest))(
1120
- request,
1121
- ).pipe(Effect.mapError(internalFailure(operation)));
1389
+
1390
+ const validated = yield* Schema.decodeEffect(Schema.toType(ClaimRequest))(request).pipe(
1391
+ Effect.mapError(internalFailure(operation)),
1392
+ );
1393
+
1122
1394
  const attemptId = yield* mintIdentifier("attempt", operation);
1123
1395
  const ownershipToken = yield* mintIdentifier("owner", operation);
1396
+
1124
1397
  yield* hitFailpoint("ledger:claim:before", operation);
1398
+
1125
1399
  const claimed = yield* inWriteTransaction(
1126
1400
  operation,
1127
1401
  Effect.gen(function* () {
1128
1402
  const headRows = yield* sql<Record<string, unknown>>`
1129
1403
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
1130
1404
  FROM effect_agent_submissions
1131
- WHERE conversation_id = ${validated.conversationId}
1405
+ WHERE thread_id = ${validated.threadId}
1132
1406
  AND state <> 'settled'
1133
1407
  ORDER BY queue_sequence ASC
1134
1408
  LIMIT 1
1135
1409
  `.pipe(Effect.mapError(sqlFailure(operation)));
1136
- const heads = yield* decodeSubmissionRows(operation, validated.conversationId, headRows);
1410
+
1411
+ const heads = yield* decodeSubmissionRows(operation, validated.threadId, headRows);
1412
+
1137
1413
  if (heads.length === 0) return Option.none<Claim>();
1138
1414
  const head = heads[0];
1139
1415
 
1140
- // A joining/joined head is host-owned and a suspended/unknown head is durably
1141
- // blocked (DUR-017); the lane produces no claim and later ready work is never
1142
- // skipped past the blocked head (DUR-004).
1416
+ // Unknown work is claimable only for a durably requested abort: the coordinator
1417
+ // cleans up children and settles without replaying ordinary Tools. Read the intent
1418
+ // in this claim transaction; retain uncertainty evidence and all ownership fencing.
1143
1419
  if (
1144
1420
  head.state === "joining" ||
1145
1421
  head.state === "joined" ||
1146
1422
  head.state === "suspended" ||
1147
- head.state === "unknown"
1423
+ (head.state === "unknown" &&
1424
+ Option.isNone(yield* readAbortIntent(operation, head.submission_id)))
1148
1425
  ) {
1149
1426
  return Option.none<Claim>();
1150
1427
  }
1151
1428
 
1152
1429
  const now = yield* currentInstant;
1153
1430
  const ownership = yield* readOwnership(operation, head.submission_id);
1431
+
1154
1432
  if (Option.isSome(ownership)) {
1155
1433
  const expiresAt = yield* timestampMillis(
1156
1434
  operation,
1157
1435
  head.submission_id,
1158
1436
  )(ownership.value.lease_expires_at);
1437
+
1159
1438
  // A live lease blocks every new claim; expiry alone only revokes the liveness
1160
1439
  // assumption — correctness stays with producer-epoch fencing (D5).
1161
1440
  if (expiresAt > now.millis) return Option.none<Claim>();
1162
1441
  }
1163
1442
 
1164
- // Bump the Conversation's producer epoch atomically with the claim so every stale
1165
- // Attempt is fenced out of canonical appends (DUR-006). A Conversation that was
1443
+ // Bump the Thread's producer epoch atomically with the claim so every stale
1444
+ // Attempt is fenced out of canonical appends (DUR-006). A Thread that was
1166
1445
  // never materialized (crash between admission and materialization) is created here
1167
1446
  // so recovery can claim first and re-materialize idempotently at this epoch.
1168
- const conversations = yield* journal
1169
- .getConversation(head.conversation_id)
1447
+ const threads = yield* journal
1448
+ .getThread(head.thread_id)
1170
1449
  .pipe(Effect.mapError(internalFailure(operation)));
1450
+
1171
1451
  let producerEpoch: number;
1172
- if (conversations.length === 0) {
1452
+
1453
+ if (threads.length === 0) {
1173
1454
  producerEpoch = 1;
1174
1455
  yield* sql`
1175
- INSERT INTO effect_agent_conversations (
1176
- conversation_id,
1456
+ INSERT INTO effect_agent_threads (
1457
+ thread_id,
1177
1458
  created_at,
1178
1459
  tail_sequence,
1179
1460
  tail_digest,
1180
1461
  producer_epoch
1181
1462
  ) VALUES (
1182
- ${head.conversation_id},
1463
+ ${head.thread_id},
1183
1464
  ${now.iso},
1184
1465
  0,
1185
1466
  ${EMPTY_TAIL_DIGEST},
@@ -1187,15 +1468,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1187
1468
  )
1188
1469
  `.pipe(Effect.mapError(sqlFailure(operation)));
1189
1470
  } else {
1190
- producerEpoch = conversations[0].producer_epoch + 1;
1471
+ producerEpoch = threads[0].producer_epoch + 1;
1191
1472
  yield* sql`
1192
- UPDATE effect_agent_conversations
1473
+ UPDATE effect_agent_threads
1193
1474
  SET producer_epoch = ${producerEpoch}
1194
- WHERE conversation_id = ${head.conversation_id}
1475
+ WHERE thread_id = ${head.thread_id}
1195
1476
  `.pipe(Effect.mapError(sqlFailure(operation)));
1196
1477
  }
1197
1478
 
1198
1479
  const leaseExpiresAt = new Date(now.millis + config.ownershipLeaseDuration).toISOString();
1480
+
1199
1481
  yield* sql`
1200
1482
  INSERT INTO effect_agent_submission_ownership (
1201
1483
  submission_id,
@@ -1224,14 +1506,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1224
1506
  INSERT INTO effect_agent_attempts (
1225
1507
  attempt_id,
1226
1508
  submission_id,
1227
- conversation_id,
1509
+ thread_id,
1228
1510
  owner_producer_id,
1229
1511
  producer_epoch,
1230
1512
  claimed_at
1231
1513
  ) VALUES (
1232
1514
  ${attemptId},
1233
1515
  ${head.submission_id},
1234
- ${head.conversation_id},
1516
+ ${head.thread_id},
1235
1517
  ${validated.producerId},
1236
1518
  ${producerEpoch},
1237
1519
  ${now.iso}
@@ -1256,6 +1538,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1256
1538
  ),
1257
1539
  ),
1258
1540
  );
1541
+
1259
1542
  return Option.some(
1260
1543
  yield* decodeClaim({
1261
1544
  submissionId: head.submission_id,
@@ -1268,7 +1551,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1268
1551
  );
1269
1552
  }),
1270
1553
  );
1554
+
1271
1555
  yield* hitFailpoint("ledger:claim:after", operation);
1556
+
1272
1557
  return claimed;
1273
1558
  },
1274
1559
  );
@@ -1277,29 +1562,37 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1277
1562
  "SqliteSubmissionLedger.renewOwnership",
1278
1563
  )(function* (request: RenewOwnershipRequest) {
1279
1564
  const operation = "ledger renew ownership";
1280
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(RenewOwnershipRequest))(
1565
+
1566
+ const validated = yield* Schema.decodeEffect(Schema.toType(RenewOwnershipRequest))(
1281
1567
  request,
1282
1568
  ).pipe(Effect.mapError(internalFailure(operation)));
1569
+
1283
1570
  yield* hitFailpoint("ledger:renew:before", operation);
1571
+
1284
1572
  const renewal = yield* inWriteTransaction(
1285
1573
  operation,
1286
1574
  Effect.gen(function* () {
1287
1575
  const submission = yield* requireSubmission(operation, validated.submissionId);
1576
+
1288
1577
  yield* requireOwnership(operation, submission, validated.ownershipToken);
1289
1578
  const now = yield* currentInstant;
1290
1579
  const leaseExpiresAt = new Date(now.millis + config.ownershipLeaseDuration).toISOString();
1580
+
1291
1581
  yield* sql`
1292
1582
  UPDATE effect_agent_submission_ownership
1293
1583
  SET lease_expires_at = ${leaseExpiresAt}
1294
1584
  WHERE submission_id = ${validated.submissionId}
1295
1585
  `.pipe(Effect.mapError(sqlFailure(operation)));
1586
+
1296
1587
  return yield* decodeOwnershipRenewal({
1297
1588
  ownershipToken: validated.ownershipToken,
1298
1589
  leaseExpiresAt,
1299
1590
  }).pipe(Effect.mapError(internalFailure(operation)));
1300
1591
  }),
1301
1592
  );
1593
+
1302
1594
  yield* hitFailpoint("ledger:renew:after", operation);
1595
+
1303
1596
  return renewal;
1304
1597
  });
1305
1598
 
@@ -1307,14 +1600,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1307
1600
  "SqliteSubmissionLedger.releaseOwnership",
1308
1601
  )(function* (request: ReleaseOwnershipRequest) {
1309
1602
  const operation = "ledger release ownership";
1310
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ReleaseOwnershipRequest))(
1603
+
1604
+ const validated = yield* Schema.decodeEffect(Schema.toType(ReleaseOwnershipRequest))(
1311
1605
  request,
1312
1606
  ).pipe(Effect.mapError(internalFailure(operation)));
1607
+
1313
1608
  yield* hitFailpoint("ledger:release:before", operation);
1314
1609
  yield* inWriteTransaction(
1315
1610
  operation,
1316
1611
  Effect.gen(function* () {
1317
1612
  const submission = yield* requireSubmission(operation, validated.submissionId);
1613
+
1318
1614
  yield* requireOwnership(operation, submission, validated.ownershipToken);
1319
1615
  yield* sql`
1320
1616
  DELETE FROM effect_agent_submission_ownership
@@ -1336,14 +1632,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1336
1632
  "SqliteSubmissionLedger.markInputApplied",
1337
1633
  )(function* (request: MarkInputAppliedRequest) {
1338
1634
  const operation = "ledger mark input applied";
1339
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(MarkInputAppliedRequest))(
1635
+
1636
+ const validated = yield* Schema.decodeEffect(Schema.toType(MarkInputAppliedRequest))(
1340
1637
  request,
1341
1638
  ).pipe(Effect.mapError(internalFailure(operation)));
1639
+
1342
1640
  yield* hitFailpoint("ledger:mark-input-applied:before", operation);
1343
1641
  yield* inWriteTransaction(
1344
1642
  operation,
1345
1643
  Effect.gen(function* () {
1346
1644
  const submission = yield* requireSubmission(operation, validated.submissionId);
1645
+
1347
1646
  yield* requireOwnership(operation, submission, validated.ownershipToken);
1348
1647
  if (submission.input_applied_record_id !== null) {
1349
1648
  if (
@@ -1352,6 +1651,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1352
1651
  ) {
1353
1652
  return;
1354
1653
  }
1654
+
1355
1655
  return yield* corruptionFailure(
1356
1656
  operation,
1357
1657
  "effect_agent_submissions",
@@ -1379,29 +1679,36 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1379
1679
  "SqliteSubmissionLedger.reserveSettlement",
1380
1680
  )(function* (request: SettlementReservation) {
1381
1681
  const operation = "ledger reserve settlement";
1382
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SettlementReservation))(
1682
+
1683
+ const validated = yield* Schema.decodeEffect(Schema.toType(SettlementReservation))(
1383
1684
  request,
1384
1685
  ).pipe(Effect.mapError(internalFailure(operation)));
1686
+
1385
1687
  const recordJson = yield* encodeRecordEnvelopeText(validated.record).pipe(
1386
1688
  Effect.mapError(internalFailure(operation)),
1387
1689
  );
1690
+
1388
1691
  yield* hitFailpoint("ledger:reserve-settlement:before", operation);
1692
+
1389
1693
  const reserved = yield* inWriteTransaction(
1390
1694
  operation,
1391
1695
  Effect.gen(function* () {
1392
1696
  const existing = yield* readReservation(operation, validated.submissionId);
1697
+
1393
1698
  if (Option.isSome(existing)) {
1394
1699
  const identical =
1395
1700
  existing.value.settlement_id === validated.settlementId &&
1396
1701
  existing.value.outcome === validated.outcome &&
1397
1702
  existing.value.record_digest === validated.recordDigest &&
1398
1703
  existing.value.record_json === recordJson;
1704
+
1399
1705
  if (!identical) {
1400
1706
  return yield* SettlementConflict.make({
1401
1707
  submissionId: validated.submissionId,
1402
1708
  existingOutcome: existing.value.outcome,
1403
1709
  });
1404
1710
  }
1711
+
1405
1712
  const record = yield* decodeRecordEnvelopeText(existing.value.record_json).pipe(
1406
1713
  Effect.mapError((error) =>
1407
1714
  corruptionFailure(
@@ -1412,6 +1719,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1412
1719
  ),
1413
1720
  ),
1414
1721
  );
1722
+
1415
1723
  return ReservedSettlement.make({
1416
1724
  submissionId: validated.submissionId,
1417
1725
  settlementId: validated.settlementId,
@@ -1423,6 +1731,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1423
1731
  }
1424
1732
 
1425
1733
  const submission = yield* requireSubmission(operation, validated.submissionId);
1734
+
1426
1735
  if (submission.state === "settled") {
1427
1736
  if (submission.settled_outcome === null) {
1428
1737
  return yield* corruptionFailure(
@@ -1432,6 +1741,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1432
1741
  "A settled Submission carries no terminal outcome.",
1433
1742
  );
1434
1743
  }
1744
+
1435
1745
  return yield* SettlementConflict.make({
1436
1746
  submissionId: validated.submissionId,
1437
1747
  existingOutcome: submission.settled_outcome,
@@ -1446,13 +1756,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1446
1756
  // ABORTED settlement (`terminalizing` is the same pass's crash replay). Every other
1447
1757
  // reservation stays fenced by the target lane's live ownership.
1448
1758
  let queuedAbortSettlement = false;
1759
+
1449
1760
  if (
1450
1761
  validated.outcome === "aborted" &&
1451
1762
  (submission.state === "ready" || submission.state === "terminalizing")
1452
1763
  ) {
1453
1764
  const abortIntent = yield* readAbortIntent(operation, validated.submissionId);
1765
+
1454
1766
  if (Option.isSome(abortIntent)) {
1455
1767
  const ownership = yield* readOwnership(operation, validated.submissionId);
1768
+
1456
1769
  queuedAbortSettlement = Option.isNone(ownership);
1457
1770
  }
1458
1771
  }
@@ -1461,6 +1774,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1461
1774
  }
1462
1775
  }
1463
1776
  const now = yield* currentInstant;
1777
+
1464
1778
  yield* sql`
1465
1779
  INSERT INTO effect_agent_settlement_reservations (
1466
1780
  submission_id,
@@ -1485,6 +1799,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1485
1799
  SET state = 'terminalizing'
1486
1800
  WHERE submission_id = ${validated.submissionId}
1487
1801
  `.pipe(Effect.mapError(sqlFailure(operation)));
1802
+
1488
1803
  return ReservedSettlement.make({
1489
1804
  submissionId: validated.submissionId,
1490
1805
  settlementId: validated.settlementId,
@@ -1495,56 +1810,161 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1495
1810
  });
1496
1811
  }),
1497
1812
  );
1813
+
1498
1814
  yield* hitFailpoint("ledger:reserve-settlement:after", operation);
1815
+
1499
1816
  return reserved;
1500
1817
  });
1501
1818
 
1819
+ const validateFinalization = Effect.fn("SqliteSubmissionLedger.validateFinalization")(function* (
1820
+ validated: SettlementFinalization,
1821
+ reservation: Option.Option<ReservationRow>,
1822
+ ) {
1823
+ const operation = "ledger finalize settlement";
1824
+
1825
+ if (Option.isNone(reservation)) {
1826
+ return yield* LedgerError.make({
1827
+ operation,
1828
+ message: `No settlement reservation exists for submission ${validated.submissionId}.`,
1829
+ });
1830
+ }
1831
+ if (reservation.value.settlement_id !== validated.settlementId) {
1832
+ return yield* SettlementConflict.make({
1833
+ submissionId: validated.submissionId,
1834
+ existingOutcome: reservation.value.outcome,
1835
+ });
1836
+ }
1837
+
1838
+ const reservationRecord = yield* decodeRecordEnvelopeText(reservation.value.record_json).pipe(
1839
+ Effect.mapError((error) =>
1840
+ corruptionFailure(
1841
+ operation,
1842
+ "effect_agent_settlement_reservations",
1843
+ validated.submissionId,
1844
+ error.message,
1845
+ ),
1846
+ ),
1847
+ );
1848
+
1849
+ const settlementFailure = settlementFailureFromRecord(reservationRecord);
1850
+
1851
+ if ((reservation.value.outcome === "failed") !== (settlementFailure !== undefined)) {
1852
+ return yield* corruptionFailure(
1853
+ operation,
1854
+ "effect_agent_settlement_reservations",
1855
+ validated.submissionId,
1856
+ "The reserved outcome and canonical failure diagnostic disagree.",
1857
+ );
1858
+ }
1859
+
1860
+ return { reservation: reservation.value, settlementFailure };
1861
+ });
1862
+
1863
+ const replayFinalization = Effect.fn("SqliteSubmissionLedger.replayFinalization")(function* (
1864
+ validated: SettlementFinalization,
1865
+ submission: SubmissionRow,
1866
+ { reservation, settlementFailure }: Effect.Success<ReturnType<typeof validateFinalization>>,
1867
+ ) {
1868
+ const operation = "ledger finalize settlement";
1869
+
1870
+ if (reservation.finalized_at === null) {
1871
+ return yield* corruptionFailure(
1872
+ operation,
1873
+ "effect_agent_settlement_reservations",
1874
+ validated.submissionId,
1875
+ "A settled Submission's reservation carries no finalization timestamp.",
1876
+ );
1877
+ }
1878
+
1879
+ return yield* decodeSettlement({
1880
+ submissionId: validated.submissionId,
1881
+ settlementId: validated.settlementId,
1882
+ receiptId: submission.receipt_id,
1883
+ outcome: reservation.outcome,
1884
+ ...(settlementFailure === undefined ? {} : { failure: settlementFailure }),
1885
+ settledAt: reservation.finalized_at,
1886
+ }).pipe(Effect.mapError(internalFailure(operation)));
1887
+ });
1888
+
1502
1889
  const finalizeSettlement: SubmissionLedger["Service"]["finalizeSettlement"] = Effect.fn(
1503
1890
  "SqliteSubmissionLedger.finalizeSettlement",
1504
1891
  )(function* (request: SettlementFinalization) {
1505
1892
  const operation = "ledger finalize settlement";
1506
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SettlementFinalization))(
1893
+
1894
+ const validated = yield* Schema.decodeEffect(Schema.toType(SettlementFinalization))(
1507
1895
  request,
1508
1896
  ).pipe(Effect.mapError(internalFailure(operation)));
1897
+
1509
1898
  yield* hitFailpoint("ledger:finalize-settlement:before", operation);
1510
- const settlement = yield* inWriteTransaction(
1511
- operation,
1512
- Effect.gen(function* () {
1513
- const reservation = yield* readReservation(operation, validated.submissionId);
1514
- if (Option.isNone(reservation)) {
1515
- return yield* LedgerError.make({
1899
+
1900
+ // A single statement captures the settled row and its immutable reservation together.
1901
+ // A miss does not authorize finalization: the write path re-reads under its transaction.
1902
+ const replayRows = yield* sql<Record<string, unknown>>`
1903
+ SELECT submission.*, reservation.settlement_id, reservation.outcome,
1904
+ reservation.record_id, reservation.record_json, reservation.record_digest,
1905
+ reservation.reserved_at, reservation.finalized_at
1906
+ FROM effect_agent_submissions AS submission
1907
+ INNER JOIN effect_agent_settlement_reservations AS reservation
1908
+ ON reservation.submission_id = submission.submission_id
1909
+ WHERE submission.submission_id = ${validated.submissionId} AND submission.state = 'settled'
1910
+ `.pipe(Effect.mapError(sqlFailure(operation)));
1911
+
1912
+ if (replayRows.length > 1) {
1913
+ return yield* corruptionFailure(
1914
+ operation,
1915
+ "effect_agent_submissions",
1916
+ validated.submissionId,
1917
+ "A submission primary key returned more than one row.",
1918
+ );
1919
+ }
1920
+ const replayRow = replayRows[0];
1921
+
1922
+ if (replayRow !== undefined) {
1923
+ const reservations = yield* decodeRows(
1924
+ Schema.Array(ReservationRow),
1925
+ "effect_agent_settlement_reservations",
1926
+ validated.submissionId,
1927
+ replayRows,
1928
+ ).pipe(Effect.mapError(internalFailure(operation)));
1929
+
1930
+ const state = yield* validateFinalization(validated, Option.fromUndefinedOr(reservations[0]));
1931
+
1932
+ const submission = yield* Schema.decodeUnknownEffect(SubmissionRow)(replayRow).pipe(
1933
+ Effect.mapError((error) =>
1934
+ corruptionFailure(
1516
1935
  operation,
1517
- message: `No settlement reservation exists for submission ${validated.submissionId}.`,
1518
- });
1519
- }
1520
- if (reservation.value.settlement_id !== validated.settlementId) {
1521
- return yield* SettlementConflict.make({
1522
- submissionId: validated.submissionId,
1523
- existingOutcome: reservation.value.outcome,
1524
- });
1525
- }
1936
+ "effect_agent_submissions",
1937
+ validated.submissionId,
1938
+ error.message,
1939
+ ),
1940
+ ),
1941
+ );
1942
+
1943
+ const settlement = yield* replayFinalization(validated, submission, state);
1944
+
1945
+ yield* hitFailpoint("ledger:finalize-settlement:after", operation);
1946
+
1947
+ return settlement;
1948
+ }
1949
+
1950
+ const settlement = yield* inWriteTransaction(
1951
+ operation,
1952
+ Effect.gen(function* () {
1953
+ const state = yield* validateFinalization(
1954
+ validated,
1955
+ yield* readReservation(operation, validated.submissionId),
1956
+ );
1957
+
1958
+ const { reservation, settlementFailure } = state;
1526
1959
  const submission = yield* requireSubmission(operation, validated.submissionId);
1527
- if (submission.state === "settled") {
1528
- if (reservation.value.finalized_at === null) {
1529
- return yield* corruptionFailure(
1530
- operation,
1531
- "effect_agent_settlement_reservations",
1532
- validated.submissionId,
1533
- "A settled Submission's reservation carries no finalization timestamp.",
1534
- );
1535
- }
1536
- return yield* decodeSettlement({
1537
- submissionId: validated.submissionId,
1538
- settlementId: validated.settlementId,
1539
- receiptId: submission.receipt_id,
1540
- outcome: reservation.value.outcome,
1541
- settledAt: reservation.value.finalized_at,
1542
- }).pipe(Effect.mapError(internalFailure(operation)));
1543
- }
1960
+
1961
+ if (submission.state === "settled")
1962
+ return yield* replayFinalization(validated, submission, state);
1544
1963
  const now = yield* currentInstant;
1964
+
1545
1965
  yield* sql`
1546
1966
  UPDATE effect_agent_submissions
1547
- SET state = 'settled', settled_outcome = ${reservation.value.outcome}
1967
+ SET state = 'settled', settled_outcome = ${reservation.outcome}
1548
1968
  WHERE submission_id = ${validated.submissionId}
1549
1969
  `.pipe(Effect.mapError(sqlFailure(operation)));
1550
1970
  yield* sql`
@@ -1556,16 +1976,20 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1556
1976
  DELETE FROM effect_agent_submission_ownership
1557
1977
  WHERE submission_id = ${validated.submissionId}
1558
1978
  `.pipe(Effect.mapError(sqlFailure(operation)));
1979
+
1559
1980
  return yield* decodeSettlement({
1560
1981
  submissionId: validated.submissionId,
1561
1982
  settlementId: validated.settlementId,
1562
1983
  receiptId: submission.receipt_id,
1563
- outcome: reservation.value.outcome,
1984
+ outcome: reservation.outcome,
1985
+ ...(settlementFailure === undefined ? {} : { failure: settlementFailure }),
1564
1986
  settledAt: now.iso,
1565
1987
  }).pipe(Effect.mapError(internalFailure(operation)));
1566
1988
  }),
1567
1989
  );
1990
+
1568
1991
  yield* hitFailpoint("ledger:finalize-settlement:after", operation);
1992
+
1569
1993
  return settlement;
1570
1994
  });
1571
1995
 
@@ -1573,14 +1997,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1573
1997
  "SqliteSubmissionLedger.requestAbort",
1574
1998
  )(function* (request: AbortCommand) {
1575
1999
  const operation = "ledger request abort";
1576
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(AbortCommand))(request).pipe(
2000
+
2001
+ const validated = yield* Schema.decodeEffect(Schema.toType(AbortCommand))(request).pipe(
1577
2002
  Effect.mapError(internalFailure(operation)),
1578
2003
  );
2004
+
1579
2005
  yield* hitFailpoint("ledger:request-abort:before", operation);
2006
+
1580
2007
  const intent = yield* inWriteTransaction(
1581
2008
  operation,
1582
2009
  Effect.gen(function* () {
1583
2010
  const submission = yield* requireSubmission(operation, validated.submissionId);
2011
+
1584
2012
  if (submission.state === "settled") {
1585
2013
  if (submission.settled_outcome === null) {
1586
2014
  return yield* corruptionFailure(
@@ -1590,6 +2018,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1590
2018
  "A settled Submission carries no terminal outcome.",
1591
2019
  );
1592
2020
  }
2021
+
1593
2022
  return yield* SettlementConflict.make({
1594
2023
  submissionId: validated.submissionId,
1595
2024
  existingOutcome: submission.settled_outcome,
@@ -1607,15 +2036,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1607
2036
  "A joined Submission carries no host linkage.",
1608
2037
  );
1609
2038
  }
2039
+
1610
2040
  const hostSubmissionId = yield* decodeSubmissionId(
1611
2041
  submission.joined_host_submission_id,
1612
2042
  ).pipe(Effect.mapError(internalFailure(operation)));
2043
+
1613
2044
  return yield* JoinedToHost.make({
1614
2045
  submissionId: validated.submissionId,
1615
2046
  hostSubmissionId,
1616
2047
  });
1617
2048
  }
1618
2049
  const existing = yield* readAbortIntent(operation, validated.submissionId);
2050
+
1619
2051
  if (Option.isSome(existing)) {
1620
2052
  return yield* abortIntentFromRow(
1621
2053
  operation,
@@ -1625,6 +2057,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1625
2057
  );
1626
2058
  }
1627
2059
  const now = yield* currentInstant;
2060
+
1628
2061
  yield* sql`
1629
2062
  INSERT INTO effect_agent_abort_intents (
1630
2063
  submission_id,
@@ -1638,11 +2071,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1638
2071
  ${now.iso}
1639
2072
  )
1640
2073
  `.pipe(Effect.mapError(sqlFailure(operation)));
2074
+
1641
2075
  const canonicalRecordId = yield* canonicalAbortRecordId(
1642
2076
  operation,
1643
- submission.conversation_id,
2077
+ submission.thread_id,
1644
2078
  validated.submissionId,
1645
2079
  );
2080
+
1646
2081
  return yield* decodeAbortIntent({
1647
2082
  submissionId: validated.submissionId,
1648
2083
  author: validated.author,
@@ -1652,7 +2087,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1652
2087
  }).pipe(Effect.mapError(internalFailure(operation)));
1653
2088
  }),
1654
2089
  );
2090
+
1655
2091
  yield* hitFailpoint("ledger:request-abort:after", operation);
2092
+
1656
2093
  return intent;
1657
2094
  });
1658
2095
 
@@ -1660,31 +2097,38 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1660
2097
  "SqliteSubmissionLedger.claimJoining",
1661
2098
  )(function* (request: ClaimJoiningRequest) {
1662
2099
  const operation = "ledger claim joining";
1663
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ClaimJoiningRequest))(
1664
- request,
1665
- ).pipe(Effect.mapError(internalFailure(operation)));
2100
+
2101
+ const validated = yield* Schema.decodeEffect(Schema.toType(ClaimJoiningRequest))(request).pipe(
2102
+ Effect.mapError(internalFailure(operation)),
2103
+ );
2104
+
1666
2105
  yield* hitFailpoint("ledger:claim-joining:before", operation);
2106
+
1667
2107
  const claims = yield* inWriteTransaction(
1668
2108
  operation,
1669
2109
  Effect.gen(function* () {
1670
2110
  const host = yield* requireSubmission(operation, validated.hostSubmissionId);
1671
- if (host.conversation_id !== validated.conversationId) {
2111
+
2112
+ if (host.thread_id !== validated.threadId) {
1672
2113
  return yield* LedgerError.make({
1673
2114
  operation,
1674
- message: `Host submission ${validated.hostSubmissionId} does not belong to conversation ${validated.conversationId}.`,
2115
+ message: `Host submission ${validated.hostSubmissionId} does not belong to thread ${validated.threadId}.`,
1675
2116
  });
1676
2117
  }
1677
2118
  // The host Attempt already owns the lane; no epoch bump happens here (plan §2.5).
1678
2119
  yield* requireOwnership(operation, host, validated.ownershipToken);
2120
+
1679
2121
  const laterRows = yield* sql<Record<string, unknown>>`
1680
2122
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
1681
2123
  FROM effect_agent_submissions
1682
- WHERE conversation_id = ${validated.conversationId}
2124
+ WHERE thread_id = ${validated.threadId}
1683
2125
  AND queue_sequence > ${host.queue_sequence}
1684
2126
  ORDER BY queue_sequence ASC
1685
2127
  `.pipe(Effect.mapError(sqlFailure(operation)));
1686
- const later = yield* decodeSubmissionRows(operation, validated.conversationId, laterRows);
2128
+
2129
+ const later = yield* decodeSubmissionRows(operation, validated.threadId, laterRows);
1687
2130
  const claimed: Array<JoiningClaim> = [];
2131
+
1688
2132
  for (const row of later) {
1689
2133
  if (claimed.length >= validated.maxCount) break;
1690
2134
  // Rows already claimed by THIS host extend its contiguous prefix and are skipped;
@@ -1707,6 +2151,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1707
2151
  SET state = 'joining', joined_host_submission_id = ${validated.hostSubmissionId}
1708
2152
  WHERE submission_id = ${row.submission_id}
1709
2153
  `.pipe(Effect.mapError(sqlFailure(operation)));
2154
+
1710
2155
  const inputPayload = yield* parseStoredJsonText(row.input_json).pipe(
1711
2156
  Effect.mapError((error) =>
1712
2157
  corruptionFailure(
@@ -1717,6 +2162,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1717
2162
  ),
1718
2163
  ),
1719
2164
  );
2165
+
1720
2166
  claimed.push(
1721
2167
  yield* decodeJoiningClaim({
1722
2168
  submissionId: row.submission_id,
@@ -1725,10 +2171,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1725
2171
  }).pipe(Effect.mapError(internalFailure(operation))),
1726
2172
  );
1727
2173
  }
1728
- return claimed as ReadonlyArray<JoiningClaim>;
2174
+
2175
+ return claimed;
1729
2176
  }),
1730
2177
  );
2178
+
1731
2179
  yield* hitFailpoint("ledger:claim-joining:after", operation);
2180
+
1732
2181
  return claims;
1733
2182
  });
1734
2183
 
@@ -1736,14 +2185,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1736
2185
  "SqliteSubmissionLedger.markJoined",
1737
2186
  )(function* (request: MarkJoinedRequest) {
1738
2187
  const operation = "ledger mark joined";
1739
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(MarkJoinedRequest))(
1740
- request,
1741
- ).pipe(Effect.mapError(internalFailure(operation)));
2188
+
2189
+ const validated = yield* Schema.decodeEffect(Schema.toType(MarkJoinedRequest))(request).pipe(
2190
+ Effect.mapError(internalFailure(operation)),
2191
+ );
2192
+
1742
2193
  yield* hitFailpoint("ledger:mark-joined:before", operation);
1743
2194
  yield* inWriteTransaction(
1744
2195
  operation,
1745
2196
  Effect.gen(function* () {
1746
2197
  const submission = yield* requireSubmission(operation, validated.submissionId);
2198
+
1747
2199
  if (submission.joined_host_submission_id === null) {
1748
2200
  return yield* LedgerError.make({
1749
2201
  operation,
@@ -1751,6 +2203,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1751
2203
  });
1752
2204
  }
1753
2205
  const host = yield* requireSubmission(operation, submission.joined_host_submission_id);
2206
+
1754
2207
  // The lane is host-owned: the presented token must own the HOST's ownership period,
1755
2208
  // which also lets a later host Attempt repair a lost marker from history (DUR-016).
1756
2209
  yield* requireOwnership(operation, host, validated.ownershipToken);
@@ -1761,6 +2214,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1761
2214
  ) {
1762
2215
  return;
1763
2216
  }
2217
+
1764
2218
  return yield* corruptionFailure(
1765
2219
  operation,
1766
2220
  "effect_agent_submissions",
@@ -1791,14 +2245,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1791
2245
  "SqliteSubmissionLedger.revertJoining",
1792
2246
  )(function* (request: RevertJoiningRequest) {
1793
2247
  const operation = "ledger revert joining";
1794
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(RevertJoiningRequest))(
1795
- request,
1796
- ).pipe(Effect.mapError(internalFailure(operation)));
2248
+
2249
+ const validated = yield* Schema.decodeEffect(Schema.toType(RevertJoiningRequest))(request).pipe(
2250
+ Effect.mapError(internalFailure(operation)),
2251
+ );
2252
+
1797
2253
  yield* hitFailpoint("ledger:revert-joining:before", operation);
1798
2254
  yield* inWriteTransaction(
1799
2255
  operation,
1800
2256
  Effect.gen(function* () {
1801
2257
  const submission = yield* requireSubmission(operation, validated.submissionId);
2258
+
1802
2259
  // Idempotent and recovery-only: only a still-`joining` Submission reverts; an
1803
2260
  // already-joined (or already-reverted) Submission is a no-op (DUR-016).
1804
2261
  if (submission.state !== "joining") return;
@@ -1816,17 +2273,22 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1816
2273
  "SqliteSubmissionLedger.suspend",
1817
2274
  )(function* (request: SuspendRequest) {
1818
2275
  const operation = "ledger suspend";
1819
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SuspendRequest))(
1820
- request,
1821
- ).pipe(Effect.mapError(internalFailure(operation)));
2276
+
2277
+ const validated = yield* Schema.decodeEffect(Schema.toType(SuspendRequest))(request).pipe(
2278
+ Effect.mapError(internalFailure(operation)),
2279
+ );
2280
+
1822
2281
  const reasonJson = yield* encodeSuspensionReasonText(validated.reason).pipe(
1823
2282
  Effect.mapError(internalFailure(operation)),
1824
2283
  );
2284
+
1825
2285
  yield* hitFailpoint("ledger:suspend:before", operation);
2286
+
1826
2287
  const outcome = yield* inWriteTransaction(
1827
2288
  operation,
1828
2289
  Effect.gen(function* () {
1829
2290
  const submission = yield* requireSubmission(operation, validated.submissionId);
2291
+
1830
2292
  if (submission.state === "settled") {
1831
2293
  if (submission.settled_outcome === null) {
1832
2294
  return yield* corruptionFailure(
@@ -1836,6 +2298,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1836
2298
  "A settled Submission carries no terminal outcome.",
1837
2299
  );
1838
2300
  }
2301
+
1839
2302
  return yield* SettlementConflict.make({
1840
2303
  submissionId: validated.submissionId,
1841
2304
  existingOutcome: submission.settled_outcome,
@@ -1844,6 +2307,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1844
2307
  // An exact terminal outcome is already reserved (DUR-011); suspension would
1845
2308
  // contradict it, so the reservation wins.
1846
2309
  const reservation = yield* readReservation(operation, validated.submissionId);
2310
+
1847
2311
  if (Option.isSome(reservation)) {
1848
2312
  return yield* SettlementConflict.make({
1849
2313
  submissionId: validated.submissionId,
@@ -1857,23 +2321,27 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1857
2321
  if (validated.reason._tag === "ApprovalPending") {
1858
2322
  const decisions = yield* readApprovalDecisions(operation, validated.submissionId);
1859
2323
  const decided = new Set(decisions.map((row) => row.tool_call_id));
2324
+
1860
2325
  if (validated.reason.toolCallIds.every((toolCallId) => decided.has(toolCallId))) {
1861
- return "resume-immediately" as SuspensionOutcome;
2326
+ return RESUME_IMMEDIATELY;
1862
2327
  }
1863
2328
  } else {
1864
2329
  let allSettled = true;
2330
+
1865
2331
  for (const child of validated.reason.children) {
1866
2332
  const childRow = yield* readSubmission(operation, child.childSubmissionId);
2333
+
1867
2334
  if (Option.isNone(childRow) || childRow.value.state !== "settled") {
1868
2335
  allSettled = false;
1869
2336
  break;
1870
2337
  }
1871
2338
  }
1872
2339
  if (allSettled) {
1873
- return "resume-immediately" as SuspensionOutcome;
2340
+ return RESUME_IMMEDIATELY;
1874
2341
  }
1875
2342
  }
1876
2343
  const now = yield* currentInstant;
2344
+
1877
2345
  yield* sql`
1878
2346
  UPDATE effect_agent_submissions
1879
2347
  SET
@@ -1888,10 +2356,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1888
2356
  DELETE FROM effect_agent_submission_ownership
1889
2357
  WHERE submission_id = ${validated.submissionId}
1890
2358
  `.pipe(Effect.mapError(sqlFailure(operation)));
1891
- return "suspended" as SuspensionOutcome;
2359
+
2360
+ return SUSPENDED;
1892
2361
  }),
1893
2362
  );
2363
+
1894
2364
  yield* hitFailpoint("ledger:suspend:after", operation);
2365
+
1895
2366
  return outcome;
1896
2367
  });
1897
2368
 
@@ -1904,6 +2375,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1904
2375
  const wakeSuspendedIfCovered = Effect.fn("SqliteSubmissionLedger.wakeSuspendedIfCovered")(
1905
2376
  function* (operation: string, submission: SubmissionRow): Effect.fn.Return<void, LedgerError> {
1906
2377
  if (submission.state !== "suspended" || submission.suspended_reason_json === null) return;
2378
+
1907
2379
  const reason = yield* Schema.decodeEffect(Schema.fromJsonString(SuspensionReason))(
1908
2380
  submission.suspended_reason_json,
1909
2381
  ).pipe(
@@ -1916,9 +2388,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1916
2388
  ),
1917
2389
  ),
1918
2390
  );
2391
+
1919
2392
  if (reason._tag !== "ApprovalPending") return;
1920
2393
  const decisions = yield* readApprovalDecisions(operation, submission.submission_id);
1921
2394
  const decided = new Set(decisions.map((row) => row.tool_call_id));
2395
+
1922
2396
  if (!reason.toolCallIds.every((toolCallId) => decided.has(toolCallId))) return;
1923
2397
  yield* sql`
1924
2398
  UPDATE effect_agent_submissions
@@ -1935,14 +2409,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1935
2409
  "SqliteSubmissionLedger.recordApprovalDecision",
1936
2410
  )(function* (command: ApprovalDecisionCommand) {
1937
2411
  const operation = "ledger record approval decision";
1938
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ApprovalDecisionCommand))(
2412
+
2413
+ const validated = yield* Schema.decodeEffect(Schema.toType(ApprovalDecisionCommand))(
1939
2414
  command,
1940
2415
  ).pipe(Effect.mapError(internalFailure(operation)));
2416
+
1941
2417
  yield* hitFailpoint("ledger:approval-decision:before", operation);
2418
+
1942
2419
  const intent = yield* inWriteTransaction(
1943
2420
  operation,
1944
2421
  Effect.gen(function* () {
1945
2422
  const submission = yield* requireSubmission(operation, validated.submissionId);
2423
+
1946
2424
  if (submission.state === "settled") {
1947
2425
  if (submission.settled_outcome === null) {
1948
2426
  return yield* corruptionFailure(
@@ -1952,6 +2430,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1952
2430
  "A settled Submission carries no terminal outcome.",
1953
2431
  );
1954
2432
  }
2433
+
1955
2434
  return yield* SettlementConflict.make({
1956
2435
  submissionId: validated.submissionId,
1957
2436
  existingOutcome: submission.settled_outcome,
@@ -1959,6 +2438,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1959
2438
  }
1960
2439
  const decisions = yield* readApprovalDecisions(operation, validated.submissionId);
1961
2440
  const existing = decisions.find((row) => row.tool_call_id === validated.toolCallId);
2441
+
1962
2442
  if (existing !== undefined) {
1963
2443
  // Idempotent per (submissionId, toolCallId): repeating the SAME decision replays
1964
2444
  // the recorded intent unchanged; a divergent re-decision conflicts.
@@ -1969,9 +2449,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1969
2449
  existingDecision: existing.decision,
1970
2450
  });
1971
2451
  }
2452
+
1972
2453
  return yield* approvalIntentFromRow(operation, existing);
1973
2454
  }
1974
2455
  const now = yield* currentInstant;
2456
+
1975
2457
  yield* sql`
1976
2458
  INSERT INTO effect_agent_approval_decisions (
1977
2459
  submission_id,
@@ -1990,6 +2472,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1990
2472
  )
1991
2473
  `.pipe(Effect.mapError(sqlFailure(operation)));
1992
2474
  yield* wakeSuspendedIfCovered(operation, submission);
2475
+
1993
2476
  return yield* decodeApprovalDecisionIntent({
1994
2477
  submissionId: validated.submissionId,
1995
2478
  toolCallId: validated.toolCallId,
@@ -2000,7 +2483,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2000
2483
  }).pipe(Effect.mapError(internalFailure(operation)));
2001
2484
  }),
2002
2485
  );
2486
+
2003
2487
  yield* hitFailpoint("ledger:approval-decision:after", operation);
2488
+
2004
2489
  return intent;
2005
2490
  });
2006
2491
 
@@ -2008,14 +2493,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2008
2493
  "SqliteSubmissionLedger.markUnknown",
2009
2494
  )(function* (request: MarkUnknownRequest) {
2010
2495
  const operation = "ledger mark unknown";
2011
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(MarkUnknownRequest))(
2012
- request,
2013
- ).pipe(Effect.mapError(internalFailure(operation)));
2496
+
2497
+ const validated = yield* Schema.decodeEffect(Schema.toType(MarkUnknownRequest))(request).pipe(
2498
+ Effect.mapError(internalFailure(operation)),
2499
+ );
2500
+
2014
2501
  yield* hitFailpoint("ledger:mark-unknown:before", operation);
2015
2502
  yield* inWriteTransaction(
2016
2503
  operation,
2017
2504
  Effect.gen(function* () {
2018
2505
  const submission = yield* requireSubmission(operation, validated.submissionId);
2506
+
2019
2507
  if (submission.state === "settled") {
2020
2508
  if (submission.settled_outcome === null) {
2021
2509
  return yield* corruptionFailure(
@@ -2025,6 +2513,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2025
2513
  "A settled Submission carries no terminal outcome.",
2026
2514
  );
2027
2515
  }
2516
+
2028
2517
  return yield* SettlementConflict.make({
2029
2518
  submissionId: validated.submissionId,
2030
2519
  existingOutcome: submission.settled_outcome,
@@ -2033,6 +2522,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2033
2522
  // A reserved exact outcome wins over a late Unknown marking (DUR-011); the recovery
2034
2523
  // classifier orders reservation ahead of MarkUnknown for the same reason.
2035
2524
  const reservation = yield* readReservation(operation, validated.submissionId);
2525
+
2036
2526
  if (Option.isSome(reservation)) {
2037
2527
  return yield* SettlementConflict.make({
2038
2528
  submissionId: validated.submissionId,
@@ -2043,13 +2533,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2043
2533
  // set while the first recorded reason is kept.
2044
2534
  const existingIds = yield* storedUnknownToolCallIds(operation, submission);
2045
2535
  const known = new Set(existingIds);
2536
+
2046
2537
  const merged = [
2047
2538
  ...existingIds,
2048
2539
  ...validated.toolCallIds.filter((toolCallId) => !known.has(toolCallId)),
2049
2540
  ];
2541
+
2050
2542
  const idsJson = yield* encodeToolCallIdsText(merged).pipe(
2051
2543
  Effect.mapError(internalFailure(operation)),
2052
2544
  );
2545
+
2053
2546
  yield* sql`
2054
2547
  UPDATE effect_agent_submissions
2055
2548
  SET
@@ -2067,17 +2560,22 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2067
2560
  "SqliteSubmissionLedger.recordUnknownResolution",
2068
2561
  )(function* (command: UnknownResolutionCommand) {
2069
2562
  const operation = "ledger record unknown resolution";
2070
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(UnknownResolutionCommand))(
2563
+
2564
+ const validated = yield* Schema.decodeEffect(Schema.toType(UnknownResolutionCommand))(
2071
2565
  command,
2072
2566
  ).pipe(Effect.mapError(internalFailure(operation)));
2567
+
2073
2568
  const resolutionJson = yield* encodeUnknownResolutionText(validated.resolution).pipe(
2074
2569
  Effect.mapError(internalFailure(operation)),
2075
2570
  );
2571
+
2076
2572
  yield* hitFailpoint("ledger:unknown-resolution:before", operation);
2573
+
2077
2574
  const intent = yield* inWriteTransaction(
2078
2575
  operation,
2079
2576
  Effect.gen(function* () {
2080
2577
  const submission = yield* requireSubmission(operation, validated.submissionId);
2578
+
2081
2579
  if (submission.state === "settled") {
2082
2580
  if (submission.settled_outcome === null) {
2083
2581
  return yield* corruptionFailure(
@@ -2087,6 +2585,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2087
2585
  "A settled Submission carries no terminal outcome.",
2088
2586
  );
2089
2587
  }
2588
+
2090
2589
  return yield* SettlementConflict.make({
2091
2590
  submissionId: validated.submissionId,
2092
2591
  existingOutcome: submission.settled_outcome,
@@ -2094,19 +2593,30 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2094
2593
  }
2095
2594
  const resolutions = yield* readUnknownResolutions(operation, validated.submissionId);
2096
2595
  const existing = resolutions.find((row) => row.tool_call_id === validated.toolCallId);
2097
- if (existing !== undefined && existing.resolution_json !== resolutionJson) {
2596
+
2597
+ const existingIntent =
2598
+ existing === undefined
2599
+ ? undefined
2600
+ : yield* unknownResolutionIntentFromRow(operation, existing);
2601
+
2602
+ if (
2603
+ existingIntent !== undefined &&
2604
+ !equivalentUnknownResolution(existingIntent.resolution, validated.resolution)
2605
+ ) {
2098
2606
  return yield* UnknownResolutionConflict.make({
2099
2607
  submissionId: validated.submissionId,
2100
2608
  toolCallId: validated.toolCallId,
2101
2609
  });
2102
2610
  }
2103
2611
  let resolved: UnknownResolutionIntent;
2104
- if (existing !== undefined) {
2612
+
2613
+ if (existingIntent !== undefined) {
2105
2614
  // Idempotent replay of the recorded intent (author/reason may differ; the stored
2106
2615
  // audit fields win, exactly like requestAbort).
2107
- resolved = yield* unknownResolutionIntentFromRow(operation, existing);
2616
+ resolved = existingIntent;
2108
2617
  } else {
2109
2618
  const now = yield* currentInstant;
2619
+
2110
2620
  yield* sql`
2111
2621
  INSERT INTO effect_agent_unknown_resolutions (
2112
2622
  submission_id,
@@ -2124,9 +2634,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2124
2634
  ${now.iso}
2125
2635
  )
2126
2636
  `.pipe(Effect.mapError(sqlFailure(operation)));
2637
+
2127
2638
  const resolution = yield* parseStoredJsonText(resolutionJson).pipe(
2128
2639
  Effect.mapError(internalFailure(operation)),
2129
2640
  );
2641
+
2130
2642
  resolved = yield* decodeUnknownResolutionIntent({
2131
2643
  submissionId: validated.submissionId,
2132
2644
  toolCallId: validated.toolCallId,
@@ -2143,6 +2655,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2143
2655
  const markedIds = yield* storedUnknownToolCallIds(operation, submission);
2144
2656
  const covering = yield* readUnknownResolutions(operation, validated.submissionId);
2145
2657
  const coveredIds = new Set(covering.map((row) => row.tool_call_id));
2658
+
2146
2659
  if (markedIds.every((toolCallId) => coveredIds.has(toolCallId))) {
2147
2660
  yield* sql`
2148
2661
  UPDATE effect_agent_submissions
@@ -2154,10 +2667,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2154
2667
  `.pipe(Effect.mapError(sqlFailure(operation)));
2155
2668
  }
2156
2669
  }
2670
+
2157
2671
  return resolved;
2158
2672
  }),
2159
2673
  );
2674
+
2160
2675
  yield* hitFailpoint("ledger:unknown-resolution:after", operation);
2676
+
2161
2677
  return intent;
2162
2678
  });
2163
2679
 
@@ -2165,26 +2681,38 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2165
2681
  "SqliteSubmissionLedger.recordChildSettled",
2166
2682
  )(function* (request: ChildSettledNotification) {
2167
2683
  const operation = "ledger record child settled";
2168
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ChildSettledNotification))(
2684
+
2685
+ const validated = yield* Schema.decodeEffect(Schema.toType(ChildSettledNotification))(
2169
2686
  request,
2170
2687
  ).pipe(Effect.mapError(internalFailure(operation)));
2688
+
2171
2689
  yield* hitFailpoint("ledger:child-settled:before", operation);
2690
+
2172
2691
  const outcome = yield* inWriteTransaction(
2173
2692
  operation,
2174
2693
  Effect.gen(function* () {
2175
2694
  const parent = yield* requireSubmission(operation, validated.parentSubmissionId);
2176
- // The child's canonical Settlement is the authority for this wake; a notification for
2177
- // an unsettled (or unknown) child is a caller error in a single-store adapter.
2695
+ // The caller may notify after the canonical Settlement append but before ledger
2696
+ // finalization. An exact reservation plus `terminalizing` is the narrow durable prefix
2697
+ // that makes that ordering admissible; earlier states remain a caller error.
2178
2698
  const child = yield* readSubmission(operation, validated.childSubmissionId);
2179
- if (Option.isNone(child) || child.value.state !== "settled") {
2699
+ const childReservation = yield* readReservation(operation, validated.childSubmissionId);
2700
+
2701
+ const announced =
2702
+ Option.isSome(child) &&
2703
+ (child.value.state === "settled" ||
2704
+ (child.value.state === "terminalizing" && Option.isSome(childReservation)));
2705
+
2706
+ if (!announced) {
2180
2707
  return yield* LedgerError.make({
2181
2708
  operation,
2182
2709
  message: `Child submission ${validated.childSubmissionId} has no recorded settlement.`,
2183
2710
  });
2184
2711
  }
2185
2712
  if (parent.state !== "suspended" || parent.suspended_reason_json === null) {
2186
- return "not-waiting" as ChildSettledOutcome;
2713
+ return NOT_WAITING;
2187
2714
  }
2715
+
2188
2716
  const reason = yield* Schema.decodeEffect(Schema.fromJsonString(SuspensionReason))(
2189
2717
  parent.suspended_reason_json,
2190
2718
  ).pipe(
@@ -2197,20 +2725,28 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2197
2725
  ),
2198
2726
  ),
2199
2727
  );
2728
+
2200
2729
  if (reason._tag !== "WaitingForChild") {
2201
- return "not-waiting" as ChildSettledOutcome;
2730
+ return NOT_WAITING;
2202
2731
  }
2203
2732
  if (
2204
2733
  !reason.children.some((entry) => entry.childSubmissionId === validated.childSubmissionId)
2205
2734
  ) {
2206
- return "not-waiting" as ChildSettledOutcome;
2735
+ return NOT_WAITING;
2207
2736
  }
2208
- // The parent wakes exactly when EVERY listed child is settled (spec §12 step 10);
2209
- // replays re-run the coverage check so a recovering caller wakes the lane idempotently.
2737
+ // Every listed child must be either finalized or canonically announced from the exact
2738
+ // terminalizing reservation. Replays re-run this coverage check idempotently.
2210
2739
  for (const entry of reason.children) {
2211
2740
  const listed = yield* readSubmission(operation, entry.childSubmissionId);
2212
- if (Option.isNone(listed) || listed.value.state !== "settled") {
2213
- return "still-waiting" as ChildSettledOutcome;
2741
+ const reservation = yield* readReservation(operation, entry.childSubmissionId);
2742
+
2743
+ const covered =
2744
+ Option.isSome(listed) &&
2745
+ (listed.value.state === "settled" ||
2746
+ (listed.value.state === "terminalizing" && Option.isSome(reservation)));
2747
+
2748
+ if (!covered) {
2749
+ return STILL_WAITING;
2214
2750
  }
2215
2751
  }
2216
2752
  yield* sql`
@@ -2221,10 +2757,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2221
2757
  suspended_at = NULL
2222
2758
  WHERE submission_id = ${validated.parentSubmissionId}
2223
2759
  `.pipe(Effect.mapError(sqlFailure(operation)));
2224
- return "woken" as ChildSettledOutcome;
2760
+
2761
+ return WOKEN;
2225
2762
  }),
2226
2763
  );
2764
+
2227
2765
  yield* hitFailpoint("ledger:child-settled:after", operation);
2766
+
2228
2767
  return outcome;
2229
2768
  });
2230
2769
 
@@ -2232,25 +2771,36 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2232
2771
  "SqliteSubmissionLedger.reserveChildBudget",
2233
2772
  )(function* (request: ChildBudgetReservationRequest) {
2234
2773
  const operation = "ledger reserve child budget";
2235
- const validated = yield* Schema.decodeUnknownEffect(
2236
- Schema.toType(ChildBudgetReservationRequest),
2237
- )(request).pipe(Effect.mapError(internalFailure(operation)));
2774
+
2775
+ const validated = yield* Schema.decodeEffect(Schema.toType(ChildBudgetReservationRequest))(
2776
+ request,
2777
+ ).pipe(Effect.mapError(internalFailure(operation)));
2778
+
2238
2779
  const allocationJson = yield* encodePersistedJsonText(validated.allocation).pipe(
2239
2780
  Effect.mapError(internalFailure(operation)),
2240
2781
  );
2782
+
2241
2783
  yield* hitFailpoint("ledger:child-reservation:before", operation);
2784
+
2242
2785
  const reserved = yield* inWriteTransaction(
2243
2786
  operation,
2244
2787
  Effect.gen(function* () {
2245
2788
  const existing = yield* readChildReservation(operation, validated.reservationId);
2789
+
2246
2790
  if (Option.isSome(existing)) {
2791
+ const existingSnapshot = yield* childReservationSnapshotFromRow(
2792
+ operation,
2793
+ existing.value,
2794
+ );
2795
+
2247
2796
  // Identical replays short-circuit before the fence, mirroring reserveSettlement: a
2248
2797
  // replay creates nothing, so a recovering caller resumes rather than duplicates.
2249
2798
  const identical =
2250
2799
  existing.value.parent_submission_id === validated.parentSubmissionId &&
2251
2800
  existing.value.parent_tool_call_id === validated.parentToolCallId &&
2252
2801
  existing.value.allocation_digest === validated.allocationDigest &&
2253
- existing.value.allocation_json === allocationJson;
2802
+ equivalentPersistedJson(existingSnapshot.allocation, validated.allocation);
2803
+
2254
2804
  if (!identical) {
2255
2805
  return yield* ChildReservationConflict.make({
2256
2806
  reservationId: validated.reservationId,
@@ -2259,16 +2809,19 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2259
2809
  "A reservation with this identity exists with a different parent Tool Call or allocation.",
2260
2810
  });
2261
2811
  }
2812
+
2262
2813
  return ReservedChildBudget.make({
2263
- reservation: yield* childReservationSnapshotFromRow(operation, existing.value),
2814
+ reservation: existingSnapshot,
2264
2815
  replayed: true,
2265
2816
  });
2266
2817
  }
2818
+
2267
2819
  const collision = yield* readChildReservationForCall(
2268
2820
  operation,
2269
2821
  validated.parentSubmissionId,
2270
2822
  validated.parentToolCallId,
2271
2823
  );
2824
+
2272
2825
  if (Option.isSome(collision)) {
2273
2826
  return yield* ChildReservationConflict.make({
2274
2827
  reservationId: validated.reservationId,
@@ -2277,10 +2830,12 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2277
2830
  });
2278
2831
  }
2279
2832
  const parent = yield* requireSubmission(operation, validated.parentSubmissionId);
2833
+
2280
2834
  // Creation is fenced by the parent lane's live ownership (spec §12 step 2): a stale
2281
2835
  // parent Attempt can never create new reservation state.
2282
2836
  yield* requireOwnership(operation, parent, validated.ownershipToken);
2283
2837
  const now = yield* currentInstant;
2838
+
2284
2839
  yield* sql`
2285
2840
  INSERT INTO effect_agent_child_reservations (
2286
2841
  reservation_id,
@@ -2301,6 +2856,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2301
2856
  )
2302
2857
  `.pipe(Effect.mapError(sqlFailure(operation)));
2303
2858
  const inserted = yield* readChildReservation(operation, validated.reservationId);
2859
+
2304
2860
  if (Option.isNone(inserted)) {
2305
2861
  return yield* corruptionFailure(
2306
2862
  operation,
@@ -2309,13 +2865,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2309
2865
  "An inserted child reservation row is missing inside its own transaction.",
2310
2866
  );
2311
2867
  }
2868
+
2312
2869
  return ReservedChildBudget.make({
2313
2870
  reservation: yield* childReservationSnapshotFromRow(operation, inserted.value),
2314
2871
  replayed: false,
2315
2872
  });
2316
2873
  }),
2317
2874
  );
2875
+
2318
2876
  yield* hitFailpoint("ledger:child-reservation:after", operation);
2877
+
2319
2878
  return reserved;
2320
2879
  });
2321
2880
 
@@ -2324,14 +2883,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2324
2883
  request: AttachChildToReservationRequest,
2325
2884
  ) {
2326
2885
  const operation = "ledger attach child to reservation";
2327
- const validated = yield* Schema.decodeUnknownEffect(
2328
- Schema.toType(AttachChildToReservationRequest),
2329
- )(request).pipe(Effect.mapError(internalFailure(operation)));
2886
+
2887
+ const validated = yield* Schema.decodeEffect(Schema.toType(AttachChildToReservationRequest))(
2888
+ request,
2889
+ ).pipe(Effect.mapError(internalFailure(operation)));
2890
+
2330
2891
  yield* hitFailpoint("ledger:child-attach:before", operation);
2892
+
2331
2893
  const attached = yield* inWriteTransaction(
2332
2894
  operation,
2333
2895
  Effect.gen(function* () {
2334
2896
  const existing = yield* readChildReservation(operation, validated.reservationId);
2897
+
2335
2898
  if (Option.isNone(existing)) {
2336
2899
  return yield* LedgerError.make({
2337
2900
  operation,
@@ -2343,6 +2906,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2343
2906
  if (existing.value.child_submission_id === validated.childSubmissionId) {
2344
2907
  return yield* childReservationSnapshotFromRow(operation, existing.value);
2345
2908
  }
2909
+
2346
2910
  return yield* ChildReservationConflict.make({
2347
2911
  reservationId: validated.reservationId,
2348
2912
  status: existing.value.status,
@@ -2350,6 +2914,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2350
2914
  });
2351
2915
  }
2352
2916
  const parent = yield* requireSubmission(operation, existing.value.parent_submission_id);
2917
+
2353
2918
  yield* requireOwnership(operation, parent, validated.ownershipToken);
2354
2919
  if (existing.value.status !== "reserved") {
2355
2920
  return yield* ChildReservationConflict.make({
@@ -2361,6 +2926,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2361
2926
  // Single-store latitude: the admitted child must exist here, so a dangling
2362
2927
  // attachment can never enter the recovery view.
2363
2928
  const child = yield* readSubmission(operation, validated.childSubmissionId);
2929
+
2364
2930
  if (Option.isNone(child)) {
2365
2931
  return yield* LedgerError.make({
2366
2932
  operation,
@@ -2373,6 +2939,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2373
2939
  WHERE reservation_id = ${validated.reservationId}
2374
2940
  `.pipe(Effect.mapError(sqlFailure(operation)));
2375
2941
  const updated = yield* readChildReservation(operation, validated.reservationId);
2942
+
2376
2943
  if (Option.isNone(updated)) {
2377
2944
  return yield* corruptionFailure(
2378
2945
  operation,
@@ -2381,10 +2948,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2381
2948
  "An updated child reservation row is missing inside its own transaction.",
2382
2949
  );
2383
2950
  }
2951
+
2384
2952
  return yield* childReservationSnapshotFromRow(operation, updated.value);
2385
2953
  }),
2386
2954
  );
2955
+
2387
2956
  yield* hitFailpoint("ledger:child-attach:after", operation);
2957
+
2388
2958
  return attached;
2389
2959
  });
2390
2960
 
@@ -2392,17 +2962,22 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2392
2962
  "SqliteSubmissionLedger.beginChildBudgetRelease",
2393
2963
  )(function* (request: BeginChildBudgetReleaseRequest) {
2394
2964
  const operation = "ledger begin child budget release";
2395
- const validated = yield* Schema.decodeUnknownEffect(
2396
- Schema.toType(BeginChildBudgetReleaseRequest),
2397
- )(request).pipe(Effect.mapError(internalFailure(operation)));
2965
+
2966
+ const validated = yield* Schema.decodeEffect(Schema.toType(BeginChildBudgetReleaseRequest))(
2967
+ request,
2968
+ ).pipe(Effect.mapError(internalFailure(operation)));
2969
+
2398
2970
  const accountingJson = yield* encodePersistedJsonText(validated.accounting).pipe(
2399
2971
  Effect.mapError(internalFailure(operation)),
2400
2972
  );
2973
+
2401
2974
  yield* hitFailpoint("ledger:child-release-pending:before", operation);
2975
+
2402
2976
  const frozen = yield* inWriteTransaction(
2403
2977
  operation,
2404
2978
  Effect.gen(function* () {
2405
2979
  const existing = yield* readChildReservation(operation, validated.reservationId);
2980
+
2406
2981
  if (Option.isNone(existing)) {
2407
2982
  return yield* LedgerError.make({
2408
2983
  operation,
@@ -2410,11 +2985,20 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2410
2985
  });
2411
2986
  }
2412
2987
  if (existing.value.status !== "reserved") {
2988
+ const existingSnapshot = yield* childReservationSnapshotFromRow(
2989
+ operation,
2990
+ existing.value,
2991
+ );
2992
+
2413
2993
  // The accounting decision was already frozen exactly once; an identical replay is a
2414
2994
  // no-op and a divergent decision conflicts (spec §12 join step 6).
2415
- if (existing.value.accounting_json === accountingJson) {
2416
- return yield* childReservationSnapshotFromRow(operation, existing.value);
2995
+ if (
2996
+ existingSnapshot.accounting !== undefined &&
2997
+ equivalentPersistedJson(existingSnapshot.accounting, validated.accounting)
2998
+ ) {
2999
+ return existingSnapshot;
2417
3000
  }
3001
+
2418
3002
  return yield* ChildReservationConflict.make({
2419
3003
  reservationId: validated.reservationId,
2420
3004
  status: existing.value.status,
@@ -2422,6 +3006,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2422
3006
  });
2423
3007
  }
2424
3008
  const now = yield* currentInstant;
3009
+
2425
3010
  yield* sql`
2426
3011
  UPDATE effect_agent_child_reservations
2427
3012
  SET
@@ -2431,6 +3016,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2431
3016
  WHERE reservation_id = ${validated.reservationId}
2432
3017
  `.pipe(Effect.mapError(sqlFailure(operation)));
2433
3018
  const updated = yield* readChildReservation(operation, validated.reservationId);
3019
+
2434
3020
  if (Option.isNone(updated)) {
2435
3021
  return yield* corruptionFailure(
2436
3022
  operation,
@@ -2439,10 +3025,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2439
3025
  "An updated child reservation row is missing inside its own transaction.",
2440
3026
  );
2441
3027
  }
3028
+
2442
3029
  return yield* childReservationSnapshotFromRow(operation, updated.value);
2443
3030
  }),
2444
3031
  );
3032
+
2445
3033
  yield* hitFailpoint("ledger:child-release-pending:after", operation);
3034
+
2446
3035
  return frozen;
2447
3036
  });
2448
3037
 
@@ -2450,14 +3039,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2450
3039
  "SqliteSubmissionLedger.releaseChildBudget",
2451
3040
  )(function* (request: ReleaseChildBudgetRequest) {
2452
3041
  const operation = "ledger release child budget";
2453
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ReleaseChildBudgetRequest))(
3042
+
3043
+ const validated = yield* Schema.decodeEffect(Schema.toType(ReleaseChildBudgetRequest))(
2454
3044
  request,
2455
3045
  ).pipe(Effect.mapError(internalFailure(operation)));
3046
+
2456
3047
  yield* hitFailpoint("ledger:child-release:before", operation);
3048
+
2457
3049
  const released = yield* inWriteTransaction(
2458
3050
  operation,
2459
3051
  Effect.gen(function* () {
2460
3052
  const existing = yield* readChildReservation(operation, validated.reservationId);
3053
+
2461
3054
  if (Option.isNone(existing)) {
2462
3055
  return yield* LedgerError.make({
2463
3056
  operation,
@@ -2477,12 +3070,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2477
3070
  });
2478
3071
  }
2479
3072
  const now = yield* currentInstant;
3073
+
2480
3074
  yield* sql`
2481
3075
  UPDATE effect_agent_child_reservations
2482
3076
  SET status = 'released', released_at = ${now.iso}
2483
3077
  WHERE reservation_id = ${validated.reservationId}
2484
3078
  `.pipe(Effect.mapError(sqlFailure(operation)));
2485
3079
  const updated = yield* readChildReservation(operation, validated.reservationId);
3080
+
2486
3081
  if (Option.isNone(updated)) {
2487
3082
  return yield* corruptionFailure(
2488
3083
  operation,
@@ -2491,15 +3086,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2491
3086
  "An updated child reservation row is missing inside its own transaction.",
2492
3087
  );
2493
3088
  }
3089
+
2494
3090
  return yield* childReservationSnapshotFromRow(operation, updated.value);
2495
3091
  }),
2496
3092
  );
3093
+
2497
3094
  yield* hitFailpoint("ledger:child-release:after", operation);
3095
+
2498
3096
  return released;
2499
3097
  });
2500
3098
 
2501
3099
  interface ScanCursor {
2502
- readonly conversationId: string;
3100
+ readonly threadId: string;
2503
3101
  readonly queueSequence: number;
2504
3102
  }
2505
3103
 
@@ -2510,65 +3108,140 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2510
3108
  LedgerError
2511
3109
  > {
2512
3110
  const operation = "ledger scan nonterminal";
3111
+
2513
3112
  const rows = yield* (
2514
3113
  cursor === undefined
2515
3114
  ? sql<Record<string, unknown>>`
2516
3115
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
2517
3116
  FROM effect_agent_submissions
2518
3117
  WHERE state <> 'settled'
2519
- ORDER BY conversation_id ASC, queue_sequence ASC
3118
+ ORDER BY thread_id ASC, queue_sequence ASC
2520
3119
  LIMIT ${SCAN_PAGE_SIZE}
2521
3120
  `
2522
3121
  : sql<Record<string, unknown>>`
2523
3122
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
2524
3123
  FROM effect_agent_submissions
2525
3124
  WHERE state <> 'settled'
2526
- AND (
2527
- conversation_id > ${cursor.conversationId}
2528
- OR (
2529
- conversation_id = ${cursor.conversationId}
2530
- AND queue_sequence > ${cursor.queueSequence}
2531
- )
2532
- )
2533
- ORDER BY conversation_id ASC, queue_sequence ASC
3125
+ AND (thread_id, queue_sequence) > (${cursor.threadId}, ${cursor.queueSequence})
3126
+ ORDER BY thread_id ASC, queue_sequence ASC
2534
3127
  LIMIT ${SCAN_PAGE_SIZE}
2535
3128
  `
2536
3129
  ).pipe(Effect.mapError(sqlFailure(operation)));
3130
+
2537
3131
  const decoded = yield* decodeSubmissionRows(operation, "nonterminal_scan", rows);
3132
+
2538
3133
  const snapshots = yield* Effect.forEach(decoded, (row) =>
2539
3134
  decodeSubmissionSnapshot(operation, row),
2540
3135
  );
3136
+
2541
3137
  const last = decoded[decoded.length - 1];
3138
+
2542
3139
  const next: Option.Option<ScanCursor | undefined> =
2543
3140
  last === undefined || decoded.length < SCAN_PAGE_SIZE
2544
3141
  ? Option.none()
2545
3142
  : Option.some({
2546
- conversationId: last.conversation_id,
3143
+ threadId: last.thread_id,
2547
3144
  queueSequence: last.queue_sequence,
2548
3145
  });
3146
+
2549
3147
  return [snapshots, next] as const;
2550
3148
  });
2551
3149
 
2552
- const scanNonterminal: Stream.Stream<SubmissionSnapshot, LedgerError> = Stream.paginate(
2553
- undefined as ScanCursor | undefined,
2554
- scanPage,
2555
- );
3150
+ const scanNonterminal: Stream.Stream<SubmissionSnapshot, LedgerError> = Stream.paginate<
3151
+ ScanCursor | undefined,
3152
+ SubmissionSnapshot,
3153
+ LedgerError
3154
+ >(undefined, scanPage);
3155
+
3156
+ const readAbortIntentForSubmission: SubmissionLedger["Service"]["readAbortIntent"] = Effect.fn(
3157
+ "SqliteSubmissionLedger.readAbortIntentForSubmission",
3158
+ )(function* (request) {
3159
+ const operation = "ledger read abort intent";
3160
+
3161
+ const validated = yield* Schema.decodeEffect(Schema.toType(AbortIntentRequest))(request).pipe(
3162
+ Effect.mapError(internalFailure(operation)),
3163
+ );
3164
+
3165
+ const recordId = submissionAbortRecordId(validated.submissionId);
3166
+
3167
+ const rows = yield* sql<Record<string, unknown>>`
3168
+ SELECT
3169
+ submission.submission_id,
3170
+ abort.submission_id AS abort_submission_id,
3171
+ abort.author,
3172
+ abort.reason,
3173
+ abort.requested_at,
3174
+ canonical.record_id AS canonical_record_id
3175
+ FROM effect_agent_submissions AS submission
3176
+ LEFT JOIN effect_agent_abort_intents AS abort
3177
+ ON abort.submission_id = submission.submission_id
3178
+ LEFT JOIN effect_agent_canonical_records AS canonical
3179
+ ON canonical.thread_id = submission.thread_id
3180
+ AND canonical.record_id = ${recordId}
3181
+ WHERE submission.submission_id = ${validated.submissionId}
3182
+ `.pipe(Effect.mapError(sqlFailure(operation)));
3183
+
3184
+ const decoded = yield* decodeRows(
3185
+ Schema.Array(AbortIntentLookupRow),
3186
+ "effect_agent_abort_intents",
3187
+ validated.submissionId,
3188
+ rows,
3189
+ ).pipe(Effect.mapError(internalFailure(operation)));
3190
+
3191
+ if (decoded.length === 0) {
3192
+ return yield* LedgerError.make({
3193
+ operation,
3194
+ message: `Unknown submission ${validated.submissionId}.`,
3195
+ });
3196
+ }
3197
+ if (decoded.length !== 1) {
3198
+ return yield* corruptionFailure(
3199
+ operation,
3200
+ "effect_agent_abort_intents",
3201
+ validated.submissionId,
3202
+ "An abort intent lookup returned more than one row.",
3203
+ );
3204
+ }
3205
+ const row = decoded[0];
3206
+
3207
+ if (row.abort_submission_id === null) return undefined;
3208
+
3209
+ return yield* decodeAbortIntent({
3210
+ submissionId: row.abort_submission_id,
3211
+ author: row.author,
3212
+ reason: row.reason,
3213
+ requestedAt: row.requested_at,
3214
+ ...(row.canonical_record_id === null ? {} : { canonicalRecordId: row.canonical_record_id }),
3215
+ }).pipe(
3216
+ Effect.mapError((error) =>
3217
+ corruptionFailure(
3218
+ operation,
3219
+ "effect_agent_abort_intents",
3220
+ validated.submissionId,
3221
+ error.message,
3222
+ ),
3223
+ ),
3224
+ );
3225
+ });
2556
3226
 
2557
3227
  const loadRecoverySnapshot: SubmissionLedger["Service"]["loadRecoverySnapshot"] = Effect.fn(
2558
3228
  "SqliteSubmissionLedger.loadRecoverySnapshot",
2559
3229
  )(function* (request: RecoverySnapshotRequest) {
2560
3230
  const operation = "ledger load recovery snapshot";
2561
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(RecoverySnapshotRequest))(
3231
+
3232
+ const validated = yield* Schema.decodeEffect(Schema.toType(RecoverySnapshotRequest))(
2562
3233
  request,
2563
3234
  ).pipe(Effect.mapError(internalFailure(operation)));
2564
- return yield* sql
2565
- .withTransaction(
3235
+
3236
+ return yield* journal
3237
+ .withReadTransaction(operation)(
2566
3238
  Effect.gen(function* () {
2567
3239
  const submissionRow = yield* requireSubmission(operation, validated.submissionId);
2568
3240
  const submission = yield* decodeSubmissionSnapshot(operation, submissionRow);
2569
3241
 
2570
3242
  let ownership: OwnershipSnapshot | undefined;
2571
3243
  const ownershipRow = yield* readOwnership(operation, validated.submissionId);
3244
+
2572
3245
  if (Option.isSome(ownershipRow)) {
2573
3246
  ownership = yield* decodeOwnershipSnapshot({
2574
3247
  attemptId: ownershipRow.value.attempt_id,
@@ -2579,6 +3252,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2579
3252
  }
2580
3253
 
2581
3254
  let inputApplied: InputAppliedMarker | undefined;
3255
+
2582
3256
  if (
2583
3257
  submissionRow.input_applied_record_id !== null &&
2584
3258
  submissionRow.input_applied_sequence !== null
@@ -2591,6 +3265,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2591
3265
 
2592
3266
  let reservation: SettlementReservationSnapshot | undefined;
2593
3267
  const reservationRow = yield* readReservation(operation, validated.submissionId);
3268
+
2594
3269
  if (Option.isSome(reservationRow)) {
2595
3270
  const record = yield* decodeRecordEnvelopeText(reservationRow.value.record_json).pipe(
2596
3271
  Effect.mapError((error) =>
@@ -2602,9 +3277,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2602
3277
  ),
2603
3278
  ),
2604
3279
  );
2605
- const settlementId = yield* Schema.decodeUnknownEffect(
3280
+
3281
+ const settlementId = yield* Schema.decodeEffect(
2606
3282
  SettlementReservationSnapshot.fields.settlementId,
2607
3283
  )(reservationRow.value.settlement_id).pipe(Effect.mapError(internalFailure(operation)));
3284
+
2608
3285
  reservation = SettlementReservationSnapshot.make({
2609
3286
  settlementId,
2610
3287
  outcome: reservationRow.value.outcome,
@@ -2616,6 +3293,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2616
3293
 
2617
3294
  let abortIntent: AbortIntent | undefined;
2618
3295
  const abortRow = yield* readAbortIntent(operation, validated.submissionId);
3296
+
2619
3297
  if (Option.isSome(abortRow)) {
2620
3298
  abortIntent = yield* abortIntentFromRow(
2621
3299
  operation,
@@ -2633,11 +3311,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2633
3311
  WHERE joined_host_submission_id = ${validated.submissionId}
2634
3312
  ORDER BY queue_sequence ASC
2635
3313
  `.pipe(Effect.mapError(sqlFailure(operation)));
3314
+
2636
3315
  const joinSubmissions = yield* decodeSubmissionRows(
2637
3316
  operation,
2638
3317
  validated.submissionId,
2639
3318
  joinRows,
2640
3319
  );
3320
+
2641
3321
  const joins = yield* Effect.forEach(joinSubmissions, (row) =>
2642
3322
  decodeJoinSnapshot({
2643
3323
  submissionId: row.submission_id,
@@ -2647,6 +3327,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2647
3327
  );
2648
3328
 
2649
3329
  let hostSubmissionId: RecoverySnapshot["hostSubmissionId"];
3330
+
2650
3331
  if (submissionRow.joined_host_submission_id !== null) {
2651
3332
  hostSubmissionId = yield* decodeSubmissionId(
2652
3333
  submissionRow.joined_host_submission_id,
@@ -2654,6 +3335,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2654
3335
  }
2655
3336
 
2656
3337
  let suspension: SuspensionSnapshot | undefined;
3338
+
2657
3339
  if (submissionRow.suspended_reason_json !== null && submissionRow.suspended_at !== null) {
2658
3340
  const reason = yield* parseStoredJsonText(submissionRow.suspended_reason_json).pipe(
2659
3341
  Effect.mapError((error) =>
@@ -2665,6 +3347,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2665
3347
  ),
2666
3348
  ),
2667
3349
  );
3350
+
2668
3351
  suspension = yield* decodeSuspensionSnapshot({
2669
3352
  reason,
2670
3353
  suspendedAt: submissionRow.suspended_at,
@@ -2681,11 +3364,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2681
3364
  }
2682
3365
 
2683
3366
  const decisionRows = yield* readApprovalDecisions(operation, validated.submissionId);
3367
+
2684
3368
  const approvalDecisions = yield* Effect.forEach(decisionRows, (row) =>
2685
3369
  approvalIntentFromRow(operation, row),
2686
3370
  );
2687
3371
 
2688
3372
  const resolutionRows = yield* readUnknownResolutions(operation, validated.submissionId);
3373
+
2689
3374
  const unknownResolutions = yield* Effect.forEach(resolutionRows, (row) =>
2690
3375
  unknownResolutionIntentFromRow(operation, row),
2691
3376
  );
@@ -2699,18 +3384,23 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2699
3384
  WHERE parent_submission_id = ${validated.submissionId}
2700
3385
  ORDER BY parent_tool_call_id ASC
2701
3386
  `.pipe(Effect.mapError(sqlFailure(operation)));
3387
+
2702
3388
  const decodedChildReservations = yield* decodeChildReservationRows(
2703
3389
  operation,
2704
3390
  validated.submissionId,
2705
3391
  childReservationRows,
2706
3392
  );
3393
+
2707
3394
  const childReservations = yield* Effect.forEach(decodedChildReservations, (row) =>
2708
3395
  childReservationSnapshotFromRow(operation, row),
2709
3396
  );
3397
+
2710
3398
  const childAttachments: Array<ChildAttachmentSnapshot> = [];
3399
+
2711
3400
  for (const row of decodedChildReservations) {
2712
3401
  if (row.child_submission_id === null) continue;
2713
3402
  const child = yield* readSubmission(operation, row.child_submission_id);
3403
+
2714
3404
  if (Option.isNone(child)) continue;
2715
3405
  childAttachments.push(
2716
3406
  yield* decodeChildAttachmentSnapshot({
@@ -2725,6 +3415,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2725
3415
  }
2726
3416
 
2727
3417
  let parentLinkage: ParentLinkage | undefined;
3418
+
2728
3419
  if (
2729
3420
  submissionRow.parent_submission_id !== null &&
2730
3421
  submissionRow.parent_tool_call_id !== null
@@ -2752,7 +3443,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2752
3443
  });
2753
3444
  }),
2754
3445
  )
2755
- .pipe(Effect.catchTag("SqlError", (error) => Effect.fail(sqlFailure(operation)(error))));
3446
+ .pipe(
3447
+ Effect.catchTag("SqliteStorageError", (error) =>
3448
+ Effect.fail(internalFailure(operation)(error)),
3449
+ ),
3450
+ );
2756
3451
  });
2757
3452
 
2758
3453
  return Context.make(
@@ -2784,6 +3479,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2784
3479
  releaseChildBudget,
2785
3480
  scanNonterminal,
2786
3481
  loadRecoverySnapshot,
3482
+ readAbortIntent: readAbortIntentForSubmission,
2787
3483
  }),
2788
3484
  );
2789
3485
  });
@@ -2801,18 +3497,22 @@ export const submissionLedgerLayer: Layer.Layer<
2801
3497
 
2802
3498
  /**
2803
3499
  * A composition-root convenience Layer for the durable Submission Ledger. Point it at the
2804
- * same database file as the ConversationStore so claims fence the same producer epochs.
3500
+ * same database file as the ThreadStore so claims fence the same producer epochs.
2805
3501
  */
2806
3502
  export const ledgerLayer = (
2807
3503
  options: SqliteStorageOptions,
2808
3504
  ): Layer.Layer<SubmissionLedger, SqliteStorageInitializationError> =>
2809
- submissionLedgerLayer.pipe(
2810
- Layer.provide(
2811
- Layer.mergeAll(
2812
- storageConfigLayer(options),
2813
- storageFailpointLayer(options),
2814
- SqliteClient.layer({ filename: options.filename }),
2815
- NodeCrypto.layer,
3505
+ Layer.unwrap(
3506
+ Effect.map(SqliteStorageConfig, (config) =>
3507
+ submissionLedgerLayer.pipe(
3508
+ Layer.provide(
3509
+ Layer.mergeAll(
3510
+ Layer.succeed(SqliteStorageConfig)(config),
3511
+ storageFailpointLayer(options),
3512
+ SqliteClient.layer({ filename: options.filename }),
3513
+ NodeCrypto.layer,
3514
+ ),
3515
+ ),
2816
3516
  ),
2817
3517
  ),
2818
- );
3518
+ ).pipe(Layer.provide(storageConfigLayer(options)));