@effect-agent/storage-sqlite 0.1.0-beta.10 → 0.1.0-beta.101

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 (63) 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 +14 -0
  21. package/dist/SqliteStorageFailpointTesting.mjs +19 -0
  22. package/dist/SqliteStorageFailpointTesting.mjs.map +1 -0
  23. package/dist/SqliteStorageVersion-BO6U_VHq.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 +1803 -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 +464 -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-BZ89iaTY.mjs +350 -0
  39. package/dist/migrations-BZ89iaTY.mjs.map +1 -0
  40. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  41. package/dist/sqlite-journal-BCyBc87Y.mjs +1044 -0
  42. package/dist/sqlite-journal-BCyBc87Y.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} +958 -233
  53. package/src/SqliteSubscriptionStore.ts +55 -0
  54. package/src/SqliteThreadStore.ts +1004 -0
  55. package/src/index.ts +10 -6
  56. package/src/internal/message-delivery-schema.ts +24 -0
  57. package/src/internal/migrations.ts +356 -0
  58. package/src/internal/recovery-checkpoint-schema.ts +17 -0
  59. package/src/internal/sqlite-journal.ts +1684 -0
  60. package/dist/index.mjs.map +0 -1
  61. package/src/migrations.ts +0 -275
  62. package/src/sqlite-conversation-store.ts +0 -841
  63. package/src/sqlite-journal.ts +0 -1086
@@ -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,20 @@ 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);
463
- 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)));
513
+ const actualEpoch = yield* threadEpoch(operation, submission.thread_id);
514
+
515
+ if (
516
+ Option.isNone(ownership) ||
517
+ ownership.value.ownership_token !== ownershipToken ||
518
+ ownership.value.producer_epoch !== actualEpoch
519
+ ) {
520
+ const submissionId = yield* Schema.decodeEffect(SubmissionSnapshot.fields.submissionId)(
521
+ submission.submission_id,
522
+ ).pipe(Effect.mapError(internalFailure(operation)));
523
+
468
524
  return yield* OwnershipLost.make({ submissionId, actualEpoch });
469
525
  }
526
+
470
527
  return ownership.value;
471
528
  });
472
529
 
@@ -485,6 +542,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
485
542
  ),
486
543
  ),
487
544
  );
545
+
488
546
  const inputPayload = yield* parseStoredJsonText(row.input_json).pipe(
489
547
  Effect.mapError((error) =>
490
548
  corruptionFailure(
@@ -495,6 +553,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
495
553
  ),
496
554
  ),
497
555
  );
556
+
498
557
  if ((row.parent_submission_id === null) !== (row.parent_tool_call_id === null)) {
499
558
  return yield* corruptionFailure(
500
559
  operation,
@@ -503,9 +562,10 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
503
562
  "A parent linkage must record both the parent Submission and the parent Tool Call.",
504
563
  );
505
564
  }
565
+
506
566
  return yield* decodeSubmissionSnapshotUnknown({
507
567
  submissionId: row.submission_id,
508
- conversationId: row.conversation_id,
568
+ threadId: row.thread_id,
509
569
  queueSequence: row.queue_sequence,
510
570
  principal: row.principal,
511
571
  idempotencyKey: row.idempotency_key,
@@ -517,6 +577,28 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
517
577
  receiptId: row.receipt_id,
518
578
  state: row.state,
519
579
  createdAt: row.created_at,
580
+ ...(row.admission_group === null ? {} : { admissionGroup: row.admission_group }),
581
+ ...(row.worker_admission_json === null
582
+ ? {}
583
+ : {
584
+ workerAdmission: yield* parseStoredJsonText(row.worker_admission_json).pipe(
585
+ Effect.mapError(internalFailure(operation)),
586
+ ),
587
+ }),
588
+ ...(row.message_admission_json === null
589
+ ? {}
590
+ : {
591
+ messageAdmission: yield* parseStoredJsonText(row.message_admission_json).pipe(
592
+ Effect.mapError(internalFailure(operation)),
593
+ ),
594
+ }),
595
+ ...(row.admission_fence_json === null
596
+ ? {}
597
+ : {
598
+ admissionFence: yield* parseStoredJsonText(row.admission_fence_json).pipe(
599
+ Effect.mapError(internalFailure(operation)),
600
+ ),
601
+ }),
520
602
  ...(row.settled_outcome === null ? {} : { settledOutcome: row.settled_outcome }),
521
603
  ...(row.ready_at === null ? {} : { readyAt: row.ready_at }),
522
604
  ...(row.parent_submission_id === null || row.parent_tool_call_id === null
@@ -557,12 +639,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
557
639
  FROM effect_agent_settlement_reservations
558
640
  WHERE submission_id = ${submissionId}
559
641
  `.pipe(Effect.mapError(sqlFailure(operation)));
642
+
560
643
  const decoded = yield* decodeRows(
561
644
  Schema.Array(ReservationRow),
562
645
  "effect_agent_settlement_reservations",
563
646
  submissionId,
564
647
  rows,
565
648
  ).pipe(Effect.mapError(internalFailure(operation)));
649
+
566
650
  if (decoded.length > 1) {
567
651
  return yield* corruptionFailure(
568
652
  operation,
@@ -571,6 +655,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
571
655
  "A settlement reservation primary key returned more than one row.",
572
656
  );
573
657
  }
658
+
574
659
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
575
660
  });
576
661
 
@@ -588,12 +673,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
588
673
  FROM effect_agent_abort_intents
589
674
  WHERE submission_id = ${submissionId}
590
675
  `.pipe(Effect.mapError(sqlFailure(operation)));
676
+
591
677
  const decoded = yield* decodeRows(
592
678
  Schema.Array(AbortIntentRow),
593
679
  "effect_agent_abort_intents",
594
680
  submissionId,
595
681
  rows,
596
682
  ).pipe(Effect.mapError(internalFailure(operation)));
683
+
597
684
  if (decoded.length > 1) {
598
685
  return yield* corruptionFailure(
599
686
  operation,
@@ -602,6 +689,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
602
689
  "An abort intent primary key returned more than one row.",
603
690
  );
604
691
  }
692
+
605
693
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
606
694
  });
607
695
 
@@ -622,7 +710,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
622
710
  FROM effect_agent_child_reservations
623
711
  WHERE reservation_id = ${reservationId}
624
712
  `.pipe(Effect.mapError(sqlFailure(operation)));
713
+
625
714
  const decoded = yield* decodeChildReservationRows(operation, reservationId, rows);
715
+
626
716
  if (decoded.length > 1) {
627
717
  return yield* corruptionFailure(
628
718
  operation,
@@ -631,6 +721,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
631
721
  "A child reservation primary key returned more than one row.",
632
722
  );
633
723
  }
724
+
634
725
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
635
726
  });
636
727
 
@@ -647,11 +738,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
647
738
  WHERE parent_submission_id = ${parentSubmissionId}
648
739
  AND parent_tool_call_id = ${parentToolCallId}
649
740
  `.pipe(Effect.mapError(sqlFailure(operation)));
741
+
650
742
  const decoded = yield* decodeChildReservationRows(
651
743
  operation,
652
744
  `${parentSubmissionId}/${parentToolCallId}`,
653
745
  rows,
654
746
  );
747
+
655
748
  if (decoded.length > 1) {
656
749
  return yield* corruptionFailure(
657
750
  operation,
@@ -660,6 +753,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
660
753
  "A parent Tool Call returned more than one child reservation.",
661
754
  );
662
755
  }
756
+
663
757
  return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
664
758
  });
665
759
 
@@ -676,13 +770,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
676
770
  row.reservation_id,
677
771
  error.message,
678
772
  );
773
+
679
774
  const allocation = yield* parseStoredJsonText(row.allocation_json).pipe(
680
775
  Effect.mapError(rowFailure),
681
776
  );
777
+
682
778
  const accounting =
683
779
  row.accounting_json === null
684
780
  ? undefined
685
781
  : yield* parseStoredJsonText(row.accounting_json).pipe(Effect.mapError(rowFailure));
782
+
686
783
  return yield* decodeChildReservationSnapshotUnknown({
687
784
  reservationId: row.reservation_id,
688
785
  parentSubmissionId: row.parent_submission_id,
@@ -715,6 +812,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
715
812
  WHERE submission_id = ${submissionId}
716
813
  ORDER BY tool_call_id ASC
717
814
  `.pipe(Effect.mapError(sqlFailure(operation)));
815
+
718
816
  return yield* decodeRows(
719
817
  Schema.Array(ApprovalDecisionRow),
720
818
  "effect_agent_approval_decisions",
@@ -766,6 +864,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
766
864
  WHERE submission_id = ${submissionId}
767
865
  ORDER BY tool_call_id ASC
768
866
  `.pipe(Effect.mapError(sqlFailure(operation)));
867
+
769
868
  return yield* decodeRows(
770
869
  Schema.Array(UnknownResolutionRow),
771
870
  "effect_agent_unknown_resolutions",
@@ -791,6 +890,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
791
890
  ),
792
891
  ),
793
892
  );
893
+
794
894
  return yield* decodeUnknownResolutionIntent({
795
895
  submissionId: row.submission_id,
796
896
  toolCallId: row.tool_call_id,
@@ -817,6 +917,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
817
917
  submission: SubmissionRow,
818
918
  ): Effect.fn.Return<ReadonlyArray<typeof ToolCallIdSchema.Type>, LedgerError> {
819
919
  if (submission.unknown_tool_call_ids_json === null) return [];
920
+
820
921
  return yield* decodeToolCallIdsText(submission.unknown_tool_call_ids_json).pipe(
821
922
  Effect.mapError((error) =>
822
923
  corruptionFailure(
@@ -838,22 +939,25 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
838
939
  const canonicalAbortRecordId = Effect.fn("SqliteSubmissionLedger.canonicalAbortRecordId")(
839
940
  function* (
840
941
  operation: string,
841
- conversationId: string,
942
+ threadId: string,
842
943
  submissionId: SubmissionId,
843
944
  ): Effect.fn.Return<string | undefined, LedgerError> {
844
945
  const recordId = submissionAbortRecordId(submissionId);
946
+
845
947
  const rows = yield* sql<Record<string, unknown>>`
846
948
  SELECT record_id
847
949
  FROM effect_agent_canonical_records
848
- WHERE conversation_id = ${conversationId}
950
+ WHERE thread_id = ${threadId}
849
951
  AND record_id = ${recordId}
850
952
  `.pipe(Effect.mapError(sqlFailure(operation)));
953
+
851
954
  const decoded = yield* decodeRows(
852
955
  Schema.Array(CanonicalRecordIdRow),
853
956
  "effect_agent_canonical_records",
854
- `${conversationId}/${recordId}`,
957
+ `${threadId}/${recordId}`,
855
958
  rows,
856
959
  ).pipe(Effect.mapError(internalFailure(operation)));
960
+
857
961
  return decoded.length === 0 ? undefined : recordId;
858
962
  },
859
963
  );
@@ -866,9 +970,10 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
866
970
  ): Effect.fn.Return<AbortIntent, LedgerError> {
867
971
  const canonicalRecordId = yield* canonicalAbortRecordId(
868
972
  operation,
869
- submission.conversation_id,
973
+ submission.thread_id,
870
974
  submissionId,
871
975
  );
976
+
872
977
  return yield* decodeAbortIntent({
873
978
  submissionId: row.submission_id,
874
979
  author: row.author,
@@ -892,30 +997,73 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
892
997
  const admit: SubmissionLedger["Service"]["admit"] = Effect.fn("SqliteSubmissionLedger.admit")(
893
998
  function* (request: AdmissionRequest) {
894
999
  const operation = "ledger admit";
895
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(AdmissionRequest))(
896
- request,
897
- ).pipe(Effect.mapError(internalFailure(operation)));
1000
+
1001
+ const validated = yield* Schema.decodeEffect(Schema.toType(AdmissionRequest))(request).pipe(
1002
+ Effect.mapError(internalFailure(operation)),
1003
+ );
1004
+
898
1005
  const inputJson = yield* encodePersistedJsonText(validated.inputPayload).pipe(
899
1006
  Effect.mapError(internalFailure(operation)),
900
1007
  );
1008
+
1009
+ const workerAdmissionJson =
1010
+ validated.workerAdmission === undefined
1011
+ ? null
1012
+ : yield* Schema.encodeEffect(Schema.fromJsonString(WorkerAdmission))(
1013
+ validated.workerAdmission,
1014
+ ).pipe(Effect.mapError(internalFailure(operation)));
1015
+
1016
+ if (
1017
+ workerAdmissionJson !== null &&
1018
+ new TextEncoder().encode(workerAdmissionJson).byteLength > 16 * 1024 * 1024
1019
+ ) {
1020
+ return yield* LedgerError.make({
1021
+ operation,
1022
+ message: "Worker admission metadata exceeds the stored value bound",
1023
+ });
1024
+ }
1025
+
1026
+ const messageAdmissionJson =
1027
+ validated.messageAdmission === undefined
1028
+ ? null
1029
+ : yield* Schema.encodeEffect(Schema.fromJsonString(InputMessage))(
1030
+ validated.messageAdmission,
1031
+ ).pipe(Effect.mapError(internalFailure(operation)));
1032
+
1033
+ if (
1034
+ messageAdmissionJson !== null &&
1035
+ new TextEncoder().encode(messageAdmissionJson).byteLength > 16 * 1024 * 1024
1036
+ ) {
1037
+ return yield* LedgerError.make({
1038
+ operation,
1039
+ message: "Message admission metadata exceeds the stored value bound",
1040
+ });
1041
+ }
1042
+
901
1043
  const agentDigestsJson = yield* encodeDefinitionDigestsText(validated.agentDigests).pipe(
902
1044
  Effect.mapError(internalFailure(operation)),
903
1045
  );
1046
+
904
1047
  const mintedSubmissionId = yield* mintIdentifier("submission", operation);
905
1048
  const mintedReceiptId = yield* mintIdentifier("receipt", operation);
1049
+
906
1050
  yield* hitFailpoint("ledger:admit:before", operation);
1051
+
907
1052
  const result = yield* inWriteTransaction(
908
1053
  operation,
909
1054
  Effect.gen(function* () {
910
- const keyRowKey = `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`;
1055
+ const keyRowKey = `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`;
1056
+
911
1057
  const existingRows = yield* sql<Record<string, unknown>>`
912
1058
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
913
1059
  FROM effect_agent_submissions
914
- WHERE conversation_id = ${validated.conversationId}
1060
+ WHERE thread_id = ${validated.threadId}
915
1061
  AND principal = ${validated.principal}
916
1062
  AND idempotency_key = ${validated.idempotencyKey}
917
1063
  `.pipe(Effect.mapError(sqlFailure(operation)));
1064
+
918
1065
  const existing = yield* decodeSubmissionRows(operation, keyRowKey, existingRows);
1066
+
919
1067
  if (existing.length > 1) {
920
1068
  return yield* corruptionFailure(
921
1069
  operation,
@@ -933,15 +1081,61 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
933
1081
  existing[0].parent_tool_call_id === null
934
1082
  : existing[0].parent_submission_id === validated.parentLinkage.parentSubmissionId &&
935
1083
  existing[0].parent_tool_call_id === validated.parentLinkage.parentToolCallId;
1084
+
936
1085
  if (existing[0].input_digest !== validated.inputDigest || !sameLinkage) {
937
1086
  return yield* AdmissionConflict.make({
938
- conversationId: validated.conversationId,
1087
+ threadId: validated.threadId,
939
1088
  principal: validated.principal,
940
1089
  idempotencyKey: validated.idempotencyKey,
941
1090
  existingInputDigest: existing[0].input_digest,
942
1091
  attemptedInputDigest: validated.inputDigest,
943
1092
  });
944
1093
  }
1094
+
1095
+ const retainedWorkerAdmission =
1096
+ existing[0].worker_admission_json === null
1097
+ ? undefined
1098
+ : yield* Schema.decodeEffect(Schema.fromJsonString(WorkerAdmission))(
1099
+ existing[0].worker_admission_json,
1100
+ ).pipe(Effect.mapError(internalFailure(operation)));
1101
+
1102
+ const retainedInputMessage =
1103
+ existing[0].message_admission_json === null
1104
+ ? undefined
1105
+ : yield* Schema.decodeEffect(Schema.fromJsonString(InputMessage))(
1106
+ existing[0].message_admission_json,
1107
+ ).pipe(Effect.mapError(internalFailure(operation)));
1108
+
1109
+ const retainedFence =
1110
+ existing[0].admission_fence_json === null
1111
+ ? undefined
1112
+ : yield* Schema.decodeEffect(Schema.fromJsonString(AdmissionFence))(
1113
+ existing[0].admission_fence_json,
1114
+ ).pipe(Effect.mapError(internalFailure(operation)));
1115
+
1116
+ if (
1117
+ (existing[0].admission_group ?? undefined) !== validated.admissionGroup ||
1118
+ !Schema.toEquivalence(Schema.optional(WorkerAdmission))(
1119
+ retainedWorkerAdmission,
1120
+ validated.workerAdmission,
1121
+ ) ||
1122
+ !Schema.toEquivalence(Schema.optional(InputMessage))(
1123
+ retainedInputMessage,
1124
+ validated.messageAdmission,
1125
+ ) ||
1126
+ !Schema.toEquivalence(Schema.optional(AdmissionFence))(
1127
+ retainedFence,
1128
+ validated.admissionFence,
1129
+ )
1130
+ )
1131
+ return yield* AdmissionConflict.make({
1132
+ threadId: validated.threadId,
1133
+ principal: validated.principal,
1134
+ idempotencyKey: validated.idempotencyKey,
1135
+ existingInputDigest: existing[0].input_digest,
1136
+ attemptedInputDigest: validated.inputDigest,
1137
+ });
1138
+
945
1139
  return yield* decodeAdmissionResult({
946
1140
  submissionId: existing[0].submission_id,
947
1141
  receiptId: existing[0].receipt_id,
@@ -951,26 +1145,78 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
951
1145
  }).pipe(Effect.mapError(internalFailure(operation)));
952
1146
  }
953
1147
 
1148
+ // The first accepted input fixes ordinary/worker lane identity atomically with admission.
1149
+ // Canonical origin materialization can lag admission; a log scan cannot fence that race.
1150
+ const firstRows = yield* sql<Record<string, unknown>>`
1151
+ SELECT worker_admission_json FROM effect_agent_submissions
1152
+ WHERE thread_id=${validated.threadId} ORDER BY queue_sequence LIMIT 1
1153
+ `.pipe(Effect.mapError(sqlFailure(operation)));
1154
+
1155
+ const first = yield* Schema.decodeUnknownEffect(
1156
+ Schema.Array(
1157
+ Schema.Struct({
1158
+ worker_admission_json: Schema.NullOr(BoundedStoredText),
1159
+ }),
1160
+ ),
1161
+ )(firstRows).pipe(Effect.mapError(internalFailure(operation)));
1162
+
1163
+ if (first[0] !== undefined) {
1164
+ const previous =
1165
+ first[0].worker_admission_json === null
1166
+ ? undefined
1167
+ : yield* Schema.decodeEffect(Schema.fromJsonString(WorkerAdmission))(
1168
+ first[0].worker_admission_json,
1169
+ ).pipe(Effect.mapError(internalFailure(operation)));
1170
+
1171
+ if (
1172
+ !Schema.toEquivalence(Schema.optional(WorkerAdmission.fields.origin))(
1173
+ previous?.origin,
1174
+ validated.workerAdmission?.origin,
1175
+ )
1176
+ )
1177
+ return yield* AdmissionPolicyError.make({
1178
+ reason: "refused",
1179
+ code: "worker-origin-conflict",
1180
+ });
1181
+ }
1182
+
1183
+ yield* admissionFence.check(validated);
1184
+ if (validated.admissionGroup !== undefined) {
1185
+ const occupied = yield* sql<Record<string, unknown>>`
1186
+ SELECT submission_id FROM effect_agent_submissions
1187
+ WHERE thread_id=${validated.threadId} AND admission_group=${validated.admissionGroup} AND state<>'settled' LIMIT 1
1188
+ `.pipe(Effect.mapError(sqlFailure(operation)));
1189
+
1190
+ if (occupied.length > 0)
1191
+ return yield* AdmissionPolicyError.make({
1192
+ reason: "occupied",
1193
+ code: "admission-group",
1194
+ });
1195
+ }
1196
+
954
1197
  const maxRows = yield* sql<Record<string, unknown>>`
955
1198
  SELECT COALESCE(MAX(queue_sequence), 0) AS max_queue_sequence
956
1199
  FROM effect_agent_submissions
957
- WHERE conversation_id = ${validated.conversationId}
1200
+ WHERE thread_id = ${validated.threadId}
958
1201
  `.pipe(Effect.mapError(sqlFailure(operation)));
1202
+
959
1203
  const decodedMax = yield* decodeRows(
960
1204
  Schema.Array(MaxQueueSequenceRow),
961
1205
  "effect_agent_submissions",
962
- validated.conversationId,
1206
+ validated.threadId,
963
1207
  maxRows,
964
1208
  ).pipe(Effect.mapError(internalFailure(operation)));
1209
+
965
1210
  const queueSequence = yield* decodeQueueSequence(
966
1211
  (decodedMax[0]?.max_queue_sequence ?? 0) + 1,
967
1212
  ).pipe(Effect.mapError(internalFailure(operation)));
1213
+
968
1214
  const now = yield* currentInstant;
969
1215
 
970
1216
  yield* sql`
971
1217
  INSERT INTO effect_agent_submissions (
972
1218
  submission_id,
973
- conversation_id,
1219
+ thread_id,
974
1220
  queue_sequence,
975
1221
  principal,
976
1222
  idempotency_key,
@@ -983,10 +1229,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
983
1229
  state,
984
1230
  created_at,
985
1231
  parent_submission_id,
986
- parent_tool_call_id
1232
+ parent_tool_call_id,
1233
+ admission_group,
1234
+ admission_fence_json,
1235
+ worker_admission_json,
1236
+ message_admission_json
987
1237
  ) VALUES (
988
1238
  ${mintedSubmissionId},
989
- ${validated.conversationId},
1239
+ ${validated.threadId},
990
1240
  ${queueSequence},
991
1241
  ${validated.principal},
992
1242
  ${validated.idempotencyKey},
@@ -999,7 +1249,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
999
1249
  'admitted',
1000
1250
  ${now.iso},
1001
1251
  ${validated.parentLinkage?.parentSubmissionId ?? null},
1002
- ${validated.parentLinkage?.parentToolCallId ?? null}
1252
+ ${validated.parentLinkage?.parentToolCallId ?? null},
1253
+ ${validated.admissionGroup ?? null},
1254
+ ${validated.admissionFence === undefined ? null : JSON.stringify(validated.admissionFence)},
1255
+ ${workerAdmissionJson},
1256
+ ${messageAdmissionJson}
1003
1257
  )
1004
1258
  `.pipe(Effect.mapError(sqlFailure(operation)));
1005
1259
 
@@ -1012,7 +1266,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1012
1266
  }).pipe(Effect.mapError(internalFailure(operation)));
1013
1267
  }),
1014
1268
  );
1269
+
1015
1270
  yield* hitFailpoint("ledger:admit:after", operation);
1271
+
1016
1272
  return result;
1017
1273
  },
1018
1274
  );
@@ -1021,16 +1277,20 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1021
1277
  "SqliteSubmissionLedger.markReady",
1022
1278
  )(function* (request: MarkReadyRequest) {
1023
1279
  const operation = "ledger mark ready";
1024
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(MarkReadyRequest))(
1025
- request,
1026
- ).pipe(Effect.mapError(internalFailure(operation)));
1280
+
1281
+ const validated = yield* Schema.decodeEffect(Schema.toType(MarkReadyRequest))(request).pipe(
1282
+ Effect.mapError(internalFailure(operation)),
1283
+ );
1284
+
1027
1285
  yield* hitFailpoint("ledger:mark-ready:before", operation);
1028
1286
  yield* inWriteTransaction(
1029
1287
  operation,
1030
1288
  Effect.gen(function* () {
1031
1289
  const submission = yield* requireSubmission(operation, validated.submissionId);
1290
+
1032
1291
  if (submission.state !== "admitted") return;
1033
1292
  const now = yield* currentInstant;
1293
+
1034
1294
  yield* sql`
1035
1295
  UPDATE effect_agent_submissions
1036
1296
  SET state = 'ready', ready_at = ${now.iso}
@@ -1044,35 +1304,43 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1044
1304
  const lookup: SubmissionLedger["Service"]["lookup"] = Effect.fn("SqliteSubmissionLedger.lookup")(
1045
1305
  function* (request: SubmissionLookup) {
1046
1306
  const operation = "ledger lookup";
1047
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SubmissionLookup))(
1048
- request,
1049
- ).pipe(Effect.mapError(internalFailure(operation)));
1307
+
1308
+ const validated = yield* Schema.decodeEffect(Schema.toType(SubmissionLookup))(request).pipe(
1309
+ Effect.mapError(internalFailure(operation)),
1310
+ );
1311
+
1050
1312
  if (validated._tag === "SubmissionLookupById") {
1051
1313
  const row = yield* readSubmission(operation, validated.submissionId);
1314
+
1052
1315
  if (Option.isNone(row)) return Option.none();
1316
+
1053
1317
  return Option.some(yield* decodeSubmissionSnapshot(operation, row.value));
1054
1318
  }
1319
+
1055
1320
  const rows = yield* sql<Record<string, unknown>>`
1056
1321
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
1057
1322
  FROM effect_agent_submissions
1058
- WHERE conversation_id = ${validated.conversationId}
1323
+ WHERE thread_id = ${validated.threadId}
1059
1324
  AND principal = ${validated.principal}
1060
1325
  AND idempotency_key = ${validated.idempotencyKey}
1061
1326
  `.pipe(Effect.mapError(sqlFailure(operation)));
1327
+
1062
1328
  const decoded = yield* decodeSubmissionRows(
1063
1329
  operation,
1064
- `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`,
1330
+ `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
1065
1331
  rows,
1066
1332
  );
1333
+
1067
1334
  if (decoded.length > 1) {
1068
1335
  return yield* corruptionFailure(
1069
1336
  operation,
1070
1337
  "effect_agent_submissions",
1071
- `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`,
1338
+ `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
1072
1339
  "An admission idempotency key returned more than one row.",
1073
1340
  );
1074
1341
  }
1075
1342
  if (decoded.length === 0) return Option.none();
1343
+
1076
1344
  return Option.some(yield* decodeSubmissionSnapshot(operation, decoded[0]));
1077
1345
  },
1078
1346
  );
@@ -1084,30 +1352,35 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1084
1352
  "SqliteSubmissionLedger.resolveAdmission",
1085
1353
  )(function* (request: SubmissionLookupByKey) {
1086
1354
  const operation = "ledger resolve admission";
1087
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SubmissionLookupByKey))(
1355
+
1356
+ const validated = yield* Schema.decodeEffect(Schema.toType(SubmissionLookupByKey))(
1088
1357
  request,
1089
1358
  ).pipe(Effect.mapError(internalFailure(operation)));
1359
+
1090
1360
  const rows = yield* sql<Record<string, unknown>>`
1091
1361
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
1092
1362
  FROM effect_agent_submissions
1093
- WHERE conversation_id = ${validated.conversationId}
1363
+ WHERE thread_id = ${validated.threadId}
1094
1364
  AND principal = ${validated.principal}
1095
1365
  AND idempotency_key = ${validated.idempotencyKey}
1096
1366
  `.pipe(Effect.mapError(sqlFailure(operation)));
1367
+
1097
1368
  const decoded = yield* decodeSubmissionRows(
1098
1369
  operation,
1099
- `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`,
1370
+ `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
1100
1371
  rows,
1101
1372
  );
1373
+
1102
1374
  if (decoded.length > 1) {
1103
1375
  return yield* corruptionFailure(
1104
1376
  operation,
1105
1377
  "effect_agent_submissions",
1106
- `${validated.conversationId}/${validated.principal}/${validated.idempotencyKey}`,
1378
+ `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
1107
1379
  "An admission idempotency key returned more than one row.",
1108
1380
  );
1109
1381
  }
1110
1382
  if (decoded.length === 0) return AdmissionNotAdmitted.make();
1383
+
1111
1384
  return AdmissionAdmitted.make({
1112
1385
  submission: yield* decodeSubmissionSnapshot(operation, decoded[0]),
1113
1386
  });
@@ -1116,70 +1389,103 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1116
1389
  const claim: SubmissionLedger["Service"]["claim"] = Effect.fn("SqliteSubmissionLedger.claim")(
1117
1390
  function* (request: ClaimRequest) {
1118
1391
  const operation = "ledger claim";
1119
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ClaimRequest))(
1120
- request,
1121
- ).pipe(Effect.mapError(internalFailure(operation)));
1392
+
1393
+ const validated = yield* Schema.decodeEffect(Schema.toType(ClaimRequest))(request).pipe(
1394
+ Effect.mapError(internalFailure(operation)),
1395
+ );
1396
+
1122
1397
  const attemptId = yield* mintIdentifier("attempt", operation);
1123
1398
  const ownershipToken = yield* mintIdentifier("owner", operation);
1399
+
1124
1400
  yield* hitFailpoint("ledger:claim:before", operation);
1401
+
1125
1402
  const claimed = yield* inWriteTransaction(
1126
1403
  operation,
1127
1404
  Effect.gen(function* () {
1405
+ const now = yield* currentInstant;
1406
+
1407
+ // Ownership belongs to the whole Thread, including unknown work skipped below.
1408
+ // Stored lease instants are normalized UTC strings, so the latest expiry covers
1409
+ // every live lease. This check and the epoch grant share one write transaction.
1410
+ const ownershipRows = yield* sql<Record<string, unknown>>`
1411
+ SELECT ownership.*
1412
+ FROM effect_agent_submission_ownership AS ownership
1413
+ JOIN effect_agent_submissions AS submission
1414
+ ON submission.submission_id = ownership.submission_id
1415
+ WHERE submission.thread_id = ${validated.threadId}
1416
+ ORDER BY ownership.lease_expires_at DESC
1417
+ LIMIT 1
1418
+ `.pipe(Effect.mapError(sqlFailure(operation)));
1419
+
1420
+ const ownership = yield* decodeRows(
1421
+ Schema.Array(OwnershipRow),
1422
+ "effect_agent_submission_ownership",
1423
+ validated.threadId,
1424
+ ownershipRows,
1425
+ ).pipe(Effect.mapError(internalFailure(operation)));
1426
+
1427
+ if (ownership.length > 0) {
1428
+ const expiresAt = yield* timestampMillis(
1429
+ operation,
1430
+ ownership[0].submission_id,
1431
+ )(ownership[0].lease_expires_at);
1432
+
1433
+ if (expiresAt > now.millis) return Option.none<Claim>();
1434
+ }
1435
+
1128
1436
  const headRows = yield* sql<Record<string, unknown>>`
1129
1437
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
1130
1438
  FROM effect_agent_submissions
1131
- WHERE conversation_id = ${validated.conversationId}
1439
+ WHERE thread_id = ${validated.threadId}
1132
1440
  AND state <> 'settled'
1441
+ AND (
1442
+ state <> 'unknown' OR EXISTS (
1443
+ SELECT 1 FROM effect_agent_abort_intents
1444
+ WHERE submission_id = effect_agent_submissions.submission_id
1445
+ )
1446
+ )
1133
1447
  ORDER BY queue_sequence ASC
1134
1448
  LIMIT 1
1135
1449
  `.pipe(Effect.mapError(sqlFailure(operation)));
1136
- const heads = yield* decodeSubmissionRows(operation, validated.conversationId, headRows);
1450
+
1451
+ const heads = yield* decodeSubmissionRows(operation, validated.threadId, headRows);
1452
+
1137
1453
  if (heads.length === 0) return Option.none<Claim>();
1138
1454
  const head = heads[0];
1139
1455
 
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).
1456
+ // Approval/delegation suspension and joined work retain their queue barrier.
1457
+ // Unknown work with an abort intent is selected for cleanup without Tool replay.
1143
1458
  if (
1144
1459
  head.state === "joining" ||
1145
1460
  head.state === "joined" ||
1146
1461
  head.state === "suspended" ||
1147
- head.state === "unknown"
1462
+ (head.state === "unknown" &&
1463
+ Option.isNone(yield* readAbortIntent(operation, head.submission_id)))
1148
1464
  ) {
1149
1465
  return Option.none<Claim>();
1150
1466
  }
1151
1467
 
1152
- const now = yield* currentInstant;
1153
- const ownership = yield* readOwnership(operation, head.submission_id);
1154
- if (Option.isSome(ownership)) {
1155
- const expiresAt = yield* timestampMillis(
1156
- operation,
1157
- head.submission_id,
1158
- )(ownership.value.lease_expires_at);
1159
- // A live lease blocks every new claim; expiry alone only revokes the liveness
1160
- // assumption — correctness stays with producer-epoch fencing (D5).
1161
- if (expiresAt > now.millis) return Option.none<Claim>();
1162
- }
1163
-
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
1468
+ // Bump the Thread's producer epoch atomically with the claim so every stale
1469
+ // Attempt is fenced out of canonical appends (DUR-006). A Thread that was
1166
1470
  // never materialized (crash between admission and materialization) is created here
1167
1471
  // so recovery can claim first and re-materialize idempotently at this epoch.
1168
- const conversations = yield* journal
1169
- .getConversation(head.conversation_id)
1472
+ const threads = yield* journal
1473
+ .getThread(head.thread_id)
1170
1474
  .pipe(Effect.mapError(internalFailure(operation)));
1475
+
1171
1476
  let producerEpoch: number;
1172
- if (conversations.length === 0) {
1477
+
1478
+ if (threads.length === 0) {
1173
1479
  producerEpoch = 1;
1174
1480
  yield* sql`
1175
- INSERT INTO effect_agent_conversations (
1176
- conversation_id,
1481
+ INSERT INTO effect_agent_threads (
1482
+ thread_id,
1177
1483
  created_at,
1178
1484
  tail_sequence,
1179
1485
  tail_digest,
1180
1486
  producer_epoch
1181
1487
  ) VALUES (
1182
- ${head.conversation_id},
1488
+ ${head.thread_id},
1183
1489
  ${now.iso},
1184
1490
  0,
1185
1491
  ${EMPTY_TAIL_DIGEST},
@@ -1187,15 +1493,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1187
1493
  )
1188
1494
  `.pipe(Effect.mapError(sqlFailure(operation)));
1189
1495
  } else {
1190
- producerEpoch = conversations[0].producer_epoch + 1;
1496
+ producerEpoch = threads[0].producer_epoch + 1;
1191
1497
  yield* sql`
1192
- UPDATE effect_agent_conversations
1498
+ UPDATE effect_agent_threads
1193
1499
  SET producer_epoch = ${producerEpoch}
1194
- WHERE conversation_id = ${head.conversation_id}
1500
+ WHERE thread_id = ${head.thread_id}
1195
1501
  `.pipe(Effect.mapError(sqlFailure(operation)));
1196
1502
  }
1197
1503
 
1198
1504
  const leaseExpiresAt = new Date(now.millis + config.ownershipLeaseDuration).toISOString();
1505
+
1199
1506
  yield* sql`
1200
1507
  INSERT INTO effect_agent_submission_ownership (
1201
1508
  submission_id,
@@ -1224,14 +1531,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1224
1531
  INSERT INTO effect_agent_attempts (
1225
1532
  attempt_id,
1226
1533
  submission_id,
1227
- conversation_id,
1534
+ thread_id,
1228
1535
  owner_producer_id,
1229
1536
  producer_epoch,
1230
1537
  claimed_at
1231
1538
  ) VALUES (
1232
1539
  ${attemptId},
1233
1540
  ${head.submission_id},
1234
- ${head.conversation_id},
1541
+ ${head.thread_id},
1235
1542
  ${validated.producerId},
1236
1543
  ${producerEpoch},
1237
1544
  ${now.iso}
@@ -1256,6 +1563,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1256
1563
  ),
1257
1564
  ),
1258
1565
  );
1566
+
1259
1567
  return Option.some(
1260
1568
  yield* decodeClaim({
1261
1569
  submissionId: head.submission_id,
@@ -1268,7 +1576,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1268
1576
  );
1269
1577
  }),
1270
1578
  );
1579
+
1271
1580
  yield* hitFailpoint("ledger:claim:after", operation);
1581
+
1272
1582
  return claimed;
1273
1583
  },
1274
1584
  );
@@ -1277,29 +1587,37 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1277
1587
  "SqliteSubmissionLedger.renewOwnership",
1278
1588
  )(function* (request: RenewOwnershipRequest) {
1279
1589
  const operation = "ledger renew ownership";
1280
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(RenewOwnershipRequest))(
1590
+
1591
+ const validated = yield* Schema.decodeEffect(Schema.toType(RenewOwnershipRequest))(
1281
1592
  request,
1282
1593
  ).pipe(Effect.mapError(internalFailure(operation)));
1594
+
1283
1595
  yield* hitFailpoint("ledger:renew:before", operation);
1596
+
1284
1597
  const renewal = yield* inWriteTransaction(
1285
1598
  operation,
1286
1599
  Effect.gen(function* () {
1287
1600
  const submission = yield* requireSubmission(operation, validated.submissionId);
1601
+
1288
1602
  yield* requireOwnership(operation, submission, validated.ownershipToken);
1289
1603
  const now = yield* currentInstant;
1290
1604
  const leaseExpiresAt = new Date(now.millis + config.ownershipLeaseDuration).toISOString();
1605
+
1291
1606
  yield* sql`
1292
1607
  UPDATE effect_agent_submission_ownership
1293
1608
  SET lease_expires_at = ${leaseExpiresAt}
1294
1609
  WHERE submission_id = ${validated.submissionId}
1295
1610
  `.pipe(Effect.mapError(sqlFailure(operation)));
1611
+
1296
1612
  return yield* decodeOwnershipRenewal({
1297
1613
  ownershipToken: validated.ownershipToken,
1298
1614
  leaseExpiresAt,
1299
1615
  }).pipe(Effect.mapError(internalFailure(operation)));
1300
1616
  }),
1301
1617
  );
1618
+
1302
1619
  yield* hitFailpoint("ledger:renew:after", operation);
1620
+
1303
1621
  return renewal;
1304
1622
  });
1305
1623
 
@@ -1307,14 +1625,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1307
1625
  "SqliteSubmissionLedger.releaseOwnership",
1308
1626
  )(function* (request: ReleaseOwnershipRequest) {
1309
1627
  const operation = "ledger release ownership";
1310
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ReleaseOwnershipRequest))(
1628
+
1629
+ const validated = yield* Schema.decodeEffect(Schema.toType(ReleaseOwnershipRequest))(
1311
1630
  request,
1312
1631
  ).pipe(Effect.mapError(internalFailure(operation)));
1632
+
1313
1633
  yield* hitFailpoint("ledger:release:before", operation);
1314
1634
  yield* inWriteTransaction(
1315
1635
  operation,
1316
1636
  Effect.gen(function* () {
1317
1637
  const submission = yield* requireSubmission(operation, validated.submissionId);
1638
+
1318
1639
  yield* requireOwnership(operation, submission, validated.ownershipToken);
1319
1640
  yield* sql`
1320
1641
  DELETE FROM effect_agent_submission_ownership
@@ -1336,14 +1657,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1336
1657
  "SqliteSubmissionLedger.markInputApplied",
1337
1658
  )(function* (request: MarkInputAppliedRequest) {
1338
1659
  const operation = "ledger mark input applied";
1339
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(MarkInputAppliedRequest))(
1660
+
1661
+ const validated = yield* Schema.decodeEffect(Schema.toType(MarkInputAppliedRequest))(
1340
1662
  request,
1341
1663
  ).pipe(Effect.mapError(internalFailure(operation)));
1664
+
1342
1665
  yield* hitFailpoint("ledger:mark-input-applied:before", operation);
1343
1666
  yield* inWriteTransaction(
1344
1667
  operation,
1345
1668
  Effect.gen(function* () {
1346
1669
  const submission = yield* requireSubmission(operation, validated.submissionId);
1670
+
1347
1671
  yield* requireOwnership(operation, submission, validated.ownershipToken);
1348
1672
  if (submission.input_applied_record_id !== null) {
1349
1673
  if (
@@ -1352,6 +1676,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1352
1676
  ) {
1353
1677
  return;
1354
1678
  }
1679
+
1355
1680
  return yield* corruptionFailure(
1356
1681
  operation,
1357
1682
  "effect_agent_submissions",
@@ -1379,29 +1704,36 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1379
1704
  "SqliteSubmissionLedger.reserveSettlement",
1380
1705
  )(function* (request: SettlementReservation) {
1381
1706
  const operation = "ledger reserve settlement";
1382
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SettlementReservation))(
1707
+
1708
+ const validated = yield* Schema.decodeEffect(Schema.toType(SettlementReservation))(
1383
1709
  request,
1384
1710
  ).pipe(Effect.mapError(internalFailure(operation)));
1711
+
1385
1712
  const recordJson = yield* encodeRecordEnvelopeText(validated.record).pipe(
1386
1713
  Effect.mapError(internalFailure(operation)),
1387
1714
  );
1715
+
1388
1716
  yield* hitFailpoint("ledger:reserve-settlement:before", operation);
1717
+
1389
1718
  const reserved = yield* inWriteTransaction(
1390
1719
  operation,
1391
1720
  Effect.gen(function* () {
1392
1721
  const existing = yield* readReservation(operation, validated.submissionId);
1722
+
1393
1723
  if (Option.isSome(existing)) {
1394
1724
  const identical =
1395
1725
  existing.value.settlement_id === validated.settlementId &&
1396
1726
  existing.value.outcome === validated.outcome &&
1397
1727
  existing.value.record_digest === validated.recordDigest &&
1398
1728
  existing.value.record_json === recordJson;
1729
+
1399
1730
  if (!identical) {
1400
1731
  return yield* SettlementConflict.make({
1401
1732
  submissionId: validated.submissionId,
1402
1733
  existingOutcome: existing.value.outcome,
1403
1734
  });
1404
1735
  }
1736
+
1405
1737
  const record = yield* decodeRecordEnvelopeText(existing.value.record_json).pipe(
1406
1738
  Effect.mapError((error) =>
1407
1739
  corruptionFailure(
@@ -1412,6 +1744,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1412
1744
  ),
1413
1745
  ),
1414
1746
  );
1747
+
1415
1748
  return ReservedSettlement.make({
1416
1749
  submissionId: validated.submissionId,
1417
1750
  settlementId: validated.settlementId,
@@ -1423,6 +1756,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1423
1756
  }
1424
1757
 
1425
1758
  const submission = yield* requireSubmission(operation, validated.submissionId);
1759
+
1426
1760
  if (submission.state === "settled") {
1427
1761
  if (submission.settled_outcome === null) {
1428
1762
  return yield* corruptionFailure(
@@ -1432,6 +1766,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1432
1766
  "A settled Submission carries no terminal outcome.",
1433
1767
  );
1434
1768
  }
1769
+
1435
1770
  return yield* SettlementConflict.make({
1436
1771
  submissionId: validated.submissionId,
1437
1772
  existingOutcome: submission.settled_outcome,
@@ -1446,13 +1781,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1446
1781
  // ABORTED settlement (`terminalizing` is the same pass's crash replay). Every other
1447
1782
  // reservation stays fenced by the target lane's live ownership.
1448
1783
  let queuedAbortSettlement = false;
1784
+
1449
1785
  if (
1450
1786
  validated.outcome === "aborted" &&
1451
1787
  (submission.state === "ready" || submission.state === "terminalizing")
1452
1788
  ) {
1453
1789
  const abortIntent = yield* readAbortIntent(operation, validated.submissionId);
1790
+
1454
1791
  if (Option.isSome(abortIntent)) {
1455
1792
  const ownership = yield* readOwnership(operation, validated.submissionId);
1793
+
1456
1794
  queuedAbortSettlement = Option.isNone(ownership);
1457
1795
  }
1458
1796
  }
@@ -1461,6 +1799,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1461
1799
  }
1462
1800
  }
1463
1801
  const now = yield* currentInstant;
1802
+
1464
1803
  yield* sql`
1465
1804
  INSERT INTO effect_agent_settlement_reservations (
1466
1805
  submission_id,
@@ -1485,6 +1824,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1485
1824
  SET state = 'terminalizing'
1486
1825
  WHERE submission_id = ${validated.submissionId}
1487
1826
  `.pipe(Effect.mapError(sqlFailure(operation)));
1827
+
1488
1828
  return ReservedSettlement.make({
1489
1829
  submissionId: validated.submissionId,
1490
1830
  settlementId: validated.settlementId,
@@ -1495,56 +1835,161 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1495
1835
  });
1496
1836
  }),
1497
1837
  );
1838
+
1498
1839
  yield* hitFailpoint("ledger:reserve-settlement:after", operation);
1840
+
1499
1841
  return reserved;
1500
1842
  });
1501
1843
 
1502
- const finalizeSettlement: SubmissionLedger["Service"]["finalizeSettlement"] = Effect.fn(
1503
- "SqliteSubmissionLedger.finalizeSettlement",
1504
- )(function* (request: SettlementFinalization) {
1844
+ const validateFinalization = Effect.fn("SqliteSubmissionLedger.validateFinalization")(function* (
1845
+ validated: SettlementFinalization,
1846
+ reservation: Option.Option<ReservationRow>,
1847
+ ) {
1505
1848
  const operation = "ledger finalize settlement";
1506
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SettlementFinalization))(
1507
- request,
1508
- ).pipe(Effect.mapError(internalFailure(operation)));
1509
- 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({
1516
- 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
- }
1526
- 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
- }
1849
+
1850
+ if (Option.isNone(reservation)) {
1851
+ return yield* LedgerError.make({
1852
+ operation,
1853
+ message: `No settlement reservation exists for submission ${validated.submissionId}.`,
1854
+ });
1855
+ }
1856
+ if (reservation.value.settlement_id !== validated.settlementId) {
1857
+ return yield* SettlementConflict.make({
1858
+ submissionId: validated.submissionId,
1859
+ existingOutcome: reservation.value.outcome,
1860
+ });
1861
+ }
1862
+
1863
+ const reservationRecord = yield* decodeRecordEnvelopeText(reservation.value.record_json).pipe(
1864
+ Effect.mapError((error) =>
1865
+ corruptionFailure(
1866
+ operation,
1867
+ "effect_agent_settlement_reservations",
1868
+ validated.submissionId,
1869
+ error.message,
1870
+ ),
1871
+ ),
1872
+ );
1873
+
1874
+ const settlementFailure = settlementFailureFromRecord(reservationRecord);
1875
+
1876
+ if ((reservation.value.outcome === "failed") !== (settlementFailure !== undefined)) {
1877
+ return yield* corruptionFailure(
1878
+ operation,
1879
+ "effect_agent_settlement_reservations",
1880
+ validated.submissionId,
1881
+ "The reserved outcome and canonical failure diagnostic disagree.",
1882
+ );
1883
+ }
1884
+
1885
+ return { reservation: reservation.value, settlementFailure };
1886
+ });
1887
+
1888
+ const replayFinalization = Effect.fn("SqliteSubmissionLedger.replayFinalization")(function* (
1889
+ validated: SettlementFinalization,
1890
+ submission: SubmissionRow,
1891
+ { reservation, settlementFailure }: Effect.Success<ReturnType<typeof validateFinalization>>,
1892
+ ) {
1893
+ const operation = "ledger finalize settlement";
1894
+
1895
+ if (reservation.finalized_at === null) {
1896
+ return yield* corruptionFailure(
1897
+ operation,
1898
+ "effect_agent_settlement_reservations",
1899
+ validated.submissionId,
1900
+ "A settled Submission's reservation carries no finalization timestamp.",
1901
+ );
1902
+ }
1903
+
1904
+ return yield* decodeSettlement({
1905
+ submissionId: validated.submissionId,
1906
+ settlementId: validated.settlementId,
1907
+ receiptId: submission.receipt_id,
1908
+ outcome: reservation.outcome,
1909
+ ...(settlementFailure === undefined ? {} : { failure: settlementFailure }),
1910
+ settledAt: reservation.finalized_at,
1911
+ }).pipe(Effect.mapError(internalFailure(operation)));
1912
+ });
1913
+
1914
+ const finalizeSettlement: SubmissionLedger["Service"]["finalizeSettlement"] = Effect.fn(
1915
+ "SqliteSubmissionLedger.finalizeSettlement",
1916
+ )(function* (request: SettlementFinalization) {
1917
+ const operation = "ledger finalize settlement";
1918
+
1919
+ const validated = yield* Schema.decodeEffect(Schema.toType(SettlementFinalization))(
1920
+ request,
1921
+ ).pipe(Effect.mapError(internalFailure(operation)));
1922
+
1923
+ yield* hitFailpoint("ledger:finalize-settlement:before", operation);
1924
+
1925
+ // A single statement captures the settled row and its immutable reservation together.
1926
+ // A miss does not authorize finalization: the write path re-reads under its transaction.
1927
+ const replayRows = yield* sql<Record<string, unknown>>`
1928
+ SELECT submission.*, reservation.settlement_id, reservation.outcome,
1929
+ reservation.record_id, reservation.record_json, reservation.record_digest,
1930
+ reservation.reserved_at, reservation.finalized_at
1931
+ FROM effect_agent_submissions AS submission
1932
+ INNER JOIN effect_agent_settlement_reservations AS reservation
1933
+ ON reservation.submission_id = submission.submission_id
1934
+ WHERE submission.submission_id = ${validated.submissionId} AND submission.state = 'settled'
1935
+ `.pipe(Effect.mapError(sqlFailure(operation)));
1936
+
1937
+ if (replayRows.length > 1) {
1938
+ return yield* corruptionFailure(
1939
+ operation,
1940
+ "effect_agent_submissions",
1941
+ validated.submissionId,
1942
+ "A submission primary key returned more than one row.",
1943
+ );
1944
+ }
1945
+ const replayRow = replayRows[0];
1946
+
1947
+ if (replayRow !== undefined) {
1948
+ const reservations = yield* decodeRows(
1949
+ Schema.Array(ReservationRow),
1950
+ "effect_agent_settlement_reservations",
1951
+ validated.submissionId,
1952
+ replayRows,
1953
+ ).pipe(Effect.mapError(internalFailure(operation)));
1954
+
1955
+ const state = yield* validateFinalization(validated, Option.fromUndefinedOr(reservations[0]));
1956
+
1957
+ const submission = yield* Schema.decodeUnknownEffect(SubmissionRow)(replayRow).pipe(
1958
+ Effect.mapError((error) =>
1959
+ corruptionFailure(
1960
+ operation,
1961
+ "effect_agent_submissions",
1962
+ validated.submissionId,
1963
+ error.message,
1964
+ ),
1965
+ ),
1966
+ );
1967
+
1968
+ const settlement = yield* replayFinalization(validated, submission, state);
1969
+
1970
+ yield* hitFailpoint("ledger:finalize-settlement:after", operation);
1971
+
1972
+ return settlement;
1973
+ }
1974
+
1975
+ const settlement = yield* inWriteTransaction(
1976
+ operation,
1977
+ Effect.gen(function* () {
1978
+ const state = yield* validateFinalization(
1979
+ validated,
1980
+ yield* readReservation(operation, validated.submissionId),
1981
+ );
1982
+
1983
+ const { reservation, settlementFailure } = state;
1984
+ const submission = yield* requireSubmission(operation, validated.submissionId);
1985
+
1986
+ if (submission.state === "settled")
1987
+ return yield* replayFinalization(validated, submission, state);
1544
1988
  const now = yield* currentInstant;
1989
+
1545
1990
  yield* sql`
1546
1991
  UPDATE effect_agent_submissions
1547
- SET state = 'settled', settled_outcome = ${reservation.value.outcome}
1992
+ SET state = 'settled', settled_outcome = ${reservation.outcome}
1548
1993
  WHERE submission_id = ${validated.submissionId}
1549
1994
  `.pipe(Effect.mapError(sqlFailure(operation)));
1550
1995
  yield* sql`
@@ -1556,16 +2001,20 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1556
2001
  DELETE FROM effect_agent_submission_ownership
1557
2002
  WHERE submission_id = ${validated.submissionId}
1558
2003
  `.pipe(Effect.mapError(sqlFailure(operation)));
2004
+
1559
2005
  return yield* decodeSettlement({
1560
2006
  submissionId: validated.submissionId,
1561
2007
  settlementId: validated.settlementId,
1562
2008
  receiptId: submission.receipt_id,
1563
- outcome: reservation.value.outcome,
2009
+ outcome: reservation.outcome,
2010
+ ...(settlementFailure === undefined ? {} : { failure: settlementFailure }),
1564
2011
  settledAt: now.iso,
1565
2012
  }).pipe(Effect.mapError(internalFailure(operation)));
1566
2013
  }),
1567
2014
  );
2015
+
1568
2016
  yield* hitFailpoint("ledger:finalize-settlement:after", operation);
2017
+
1569
2018
  return settlement;
1570
2019
  });
1571
2020
 
@@ -1573,14 +2022,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1573
2022
  "SqliteSubmissionLedger.requestAbort",
1574
2023
  )(function* (request: AbortCommand) {
1575
2024
  const operation = "ledger request abort";
1576
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(AbortCommand))(request).pipe(
2025
+
2026
+ const validated = yield* Schema.decodeEffect(Schema.toType(AbortCommand))(request).pipe(
1577
2027
  Effect.mapError(internalFailure(operation)),
1578
2028
  );
2029
+
1579
2030
  yield* hitFailpoint("ledger:request-abort:before", operation);
2031
+
1580
2032
  const intent = yield* inWriteTransaction(
1581
2033
  operation,
1582
2034
  Effect.gen(function* () {
1583
2035
  const submission = yield* requireSubmission(operation, validated.submissionId);
2036
+
1584
2037
  if (submission.state === "settled") {
1585
2038
  if (submission.settled_outcome === null) {
1586
2039
  return yield* corruptionFailure(
@@ -1590,6 +2043,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1590
2043
  "A settled Submission carries no terminal outcome.",
1591
2044
  );
1592
2045
  }
2046
+
1593
2047
  return yield* SettlementConflict.make({
1594
2048
  submissionId: validated.submissionId,
1595
2049
  existingOutcome: submission.settled_outcome,
@@ -1607,15 +2061,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1607
2061
  "A joined Submission carries no host linkage.",
1608
2062
  );
1609
2063
  }
2064
+
1610
2065
  const hostSubmissionId = yield* decodeSubmissionId(
1611
2066
  submission.joined_host_submission_id,
1612
2067
  ).pipe(Effect.mapError(internalFailure(operation)));
2068
+
1613
2069
  return yield* JoinedToHost.make({
1614
2070
  submissionId: validated.submissionId,
1615
2071
  hostSubmissionId,
1616
2072
  });
1617
2073
  }
1618
2074
  const existing = yield* readAbortIntent(operation, validated.submissionId);
2075
+
1619
2076
  if (Option.isSome(existing)) {
1620
2077
  return yield* abortIntentFromRow(
1621
2078
  operation,
@@ -1625,6 +2082,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1625
2082
  );
1626
2083
  }
1627
2084
  const now = yield* currentInstant;
2085
+
1628
2086
  yield* sql`
1629
2087
  INSERT INTO effect_agent_abort_intents (
1630
2088
  submission_id,
@@ -1638,11 +2096,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1638
2096
  ${now.iso}
1639
2097
  )
1640
2098
  `.pipe(Effect.mapError(sqlFailure(operation)));
2099
+
1641
2100
  const canonicalRecordId = yield* canonicalAbortRecordId(
1642
2101
  operation,
1643
- submission.conversation_id,
2102
+ submission.thread_id,
1644
2103
  validated.submissionId,
1645
2104
  );
2105
+
1646
2106
  return yield* decodeAbortIntent({
1647
2107
  submissionId: validated.submissionId,
1648
2108
  author: validated.author,
@@ -1652,7 +2112,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1652
2112
  }).pipe(Effect.mapError(internalFailure(operation)));
1653
2113
  }),
1654
2114
  );
2115
+
1655
2116
  yield* hitFailpoint("ledger:request-abort:after", operation);
2117
+
1656
2118
  return intent;
1657
2119
  });
1658
2120
 
@@ -1660,31 +2122,38 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1660
2122
  "SqliteSubmissionLedger.claimJoining",
1661
2123
  )(function* (request: ClaimJoiningRequest) {
1662
2124
  const operation = "ledger claim joining";
1663
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ClaimJoiningRequest))(
1664
- request,
1665
- ).pipe(Effect.mapError(internalFailure(operation)));
2125
+
2126
+ const validated = yield* Schema.decodeEffect(Schema.toType(ClaimJoiningRequest))(request).pipe(
2127
+ Effect.mapError(internalFailure(operation)),
2128
+ );
2129
+
1666
2130
  yield* hitFailpoint("ledger:claim-joining:before", operation);
2131
+
1667
2132
  const claims = yield* inWriteTransaction(
1668
2133
  operation,
1669
2134
  Effect.gen(function* () {
1670
2135
  const host = yield* requireSubmission(operation, validated.hostSubmissionId);
1671
- if (host.conversation_id !== validated.conversationId) {
2136
+
2137
+ if (host.thread_id !== validated.threadId) {
1672
2138
  return yield* LedgerError.make({
1673
2139
  operation,
1674
- message: `Host submission ${validated.hostSubmissionId} does not belong to conversation ${validated.conversationId}.`,
2140
+ message: `Host submission ${validated.hostSubmissionId} does not belong to thread ${validated.threadId}.`,
1675
2141
  });
1676
2142
  }
1677
2143
  // The host Attempt already owns the lane; no epoch bump happens here (plan §2.5).
1678
2144
  yield* requireOwnership(operation, host, validated.ownershipToken);
2145
+
1679
2146
  const laterRows = yield* sql<Record<string, unknown>>`
1680
2147
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
1681
2148
  FROM effect_agent_submissions
1682
- WHERE conversation_id = ${validated.conversationId}
2149
+ WHERE thread_id = ${validated.threadId}
1683
2150
  AND queue_sequence > ${host.queue_sequence}
1684
2151
  ORDER BY queue_sequence ASC
1685
2152
  `.pipe(Effect.mapError(sqlFailure(operation)));
1686
- const later = yield* decodeSubmissionRows(operation, validated.conversationId, laterRows);
2153
+
2154
+ const later = yield* decodeSubmissionRows(operation, validated.threadId, laterRows);
1687
2155
  const claimed: Array<JoiningClaim> = [];
2156
+
1688
2157
  for (const row of later) {
1689
2158
  if (claimed.length >= validated.maxCount) break;
1690
2159
  // Rows already claimed by THIS host extend its contiguous prefix and are skipped;
@@ -1707,6 +2176,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1707
2176
  SET state = 'joining', joined_host_submission_id = ${validated.hostSubmissionId}
1708
2177
  WHERE submission_id = ${row.submission_id}
1709
2178
  `.pipe(Effect.mapError(sqlFailure(operation)));
2179
+
1710
2180
  const inputPayload = yield* parseStoredJsonText(row.input_json).pipe(
1711
2181
  Effect.mapError((error) =>
1712
2182
  corruptionFailure(
@@ -1717,6 +2187,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1717
2187
  ),
1718
2188
  ),
1719
2189
  );
2190
+
1720
2191
  claimed.push(
1721
2192
  yield* decodeJoiningClaim({
1722
2193
  submissionId: row.submission_id,
@@ -1725,10 +2196,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1725
2196
  }).pipe(Effect.mapError(internalFailure(operation))),
1726
2197
  );
1727
2198
  }
1728
- return claimed as ReadonlyArray<JoiningClaim>;
2199
+
2200
+ return claimed;
1729
2201
  }),
1730
2202
  );
2203
+
1731
2204
  yield* hitFailpoint("ledger:claim-joining:after", operation);
2205
+
1732
2206
  return claims;
1733
2207
  });
1734
2208
 
@@ -1736,14 +2210,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1736
2210
  "SqliteSubmissionLedger.markJoined",
1737
2211
  )(function* (request: MarkJoinedRequest) {
1738
2212
  const operation = "ledger mark joined";
1739
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(MarkJoinedRequest))(
1740
- request,
1741
- ).pipe(Effect.mapError(internalFailure(operation)));
2213
+
2214
+ const validated = yield* Schema.decodeEffect(Schema.toType(MarkJoinedRequest))(request).pipe(
2215
+ Effect.mapError(internalFailure(operation)),
2216
+ );
2217
+
1742
2218
  yield* hitFailpoint("ledger:mark-joined:before", operation);
1743
2219
  yield* inWriteTransaction(
1744
2220
  operation,
1745
2221
  Effect.gen(function* () {
1746
2222
  const submission = yield* requireSubmission(operation, validated.submissionId);
2223
+
1747
2224
  if (submission.joined_host_submission_id === null) {
1748
2225
  return yield* LedgerError.make({
1749
2226
  operation,
@@ -1751,6 +2228,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1751
2228
  });
1752
2229
  }
1753
2230
  const host = yield* requireSubmission(operation, submission.joined_host_submission_id);
2231
+
1754
2232
  // The lane is host-owned: the presented token must own the HOST's ownership period,
1755
2233
  // which also lets a later host Attempt repair a lost marker from history (DUR-016).
1756
2234
  yield* requireOwnership(operation, host, validated.ownershipToken);
@@ -1761,6 +2239,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1761
2239
  ) {
1762
2240
  return;
1763
2241
  }
2242
+
1764
2243
  return yield* corruptionFailure(
1765
2244
  operation,
1766
2245
  "effect_agent_submissions",
@@ -1791,14 +2270,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1791
2270
  "SqliteSubmissionLedger.revertJoining",
1792
2271
  )(function* (request: RevertJoiningRequest) {
1793
2272
  const operation = "ledger revert joining";
1794
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(RevertJoiningRequest))(
1795
- request,
1796
- ).pipe(Effect.mapError(internalFailure(operation)));
2273
+
2274
+ const validated = yield* Schema.decodeEffect(Schema.toType(RevertJoiningRequest))(request).pipe(
2275
+ Effect.mapError(internalFailure(operation)),
2276
+ );
2277
+
1797
2278
  yield* hitFailpoint("ledger:revert-joining:before", operation);
1798
2279
  yield* inWriteTransaction(
1799
2280
  operation,
1800
2281
  Effect.gen(function* () {
1801
2282
  const submission = yield* requireSubmission(operation, validated.submissionId);
2283
+
1802
2284
  // Idempotent and recovery-only: only a still-`joining` Submission reverts; an
1803
2285
  // already-joined (or already-reverted) Submission is a no-op (DUR-016).
1804
2286
  if (submission.state !== "joining") return;
@@ -1816,17 +2298,22 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1816
2298
  "SqliteSubmissionLedger.suspend",
1817
2299
  )(function* (request: SuspendRequest) {
1818
2300
  const operation = "ledger suspend";
1819
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(SuspendRequest))(
1820
- request,
1821
- ).pipe(Effect.mapError(internalFailure(operation)));
2301
+
2302
+ const validated = yield* Schema.decodeEffect(Schema.toType(SuspendRequest))(request).pipe(
2303
+ Effect.mapError(internalFailure(operation)),
2304
+ );
2305
+
1822
2306
  const reasonJson = yield* encodeSuspensionReasonText(validated.reason).pipe(
1823
2307
  Effect.mapError(internalFailure(operation)),
1824
2308
  );
2309
+
1825
2310
  yield* hitFailpoint("ledger:suspend:before", operation);
2311
+
1826
2312
  const outcome = yield* inWriteTransaction(
1827
2313
  operation,
1828
2314
  Effect.gen(function* () {
1829
2315
  const submission = yield* requireSubmission(operation, validated.submissionId);
2316
+
1830
2317
  if (submission.state === "settled") {
1831
2318
  if (submission.settled_outcome === null) {
1832
2319
  return yield* corruptionFailure(
@@ -1836,6 +2323,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1836
2323
  "A settled Submission carries no terminal outcome.",
1837
2324
  );
1838
2325
  }
2326
+
1839
2327
  return yield* SettlementConflict.make({
1840
2328
  submissionId: validated.submissionId,
1841
2329
  existingOutcome: submission.settled_outcome,
@@ -1844,6 +2332,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1844
2332
  // An exact terminal outcome is already reserved (DUR-011); suspension would
1845
2333
  // contradict it, so the reservation wins.
1846
2334
  const reservation = yield* readReservation(operation, validated.submissionId);
2335
+
1847
2336
  if (Option.isSome(reservation)) {
1848
2337
  return yield* SettlementConflict.make({
1849
2338
  submissionId: validated.submissionId,
@@ -1857,23 +2346,27 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1857
2346
  if (validated.reason._tag === "ApprovalPending") {
1858
2347
  const decisions = yield* readApprovalDecisions(operation, validated.submissionId);
1859
2348
  const decided = new Set(decisions.map((row) => row.tool_call_id));
2349
+
1860
2350
  if (validated.reason.toolCallIds.every((toolCallId) => decided.has(toolCallId))) {
1861
- return "resume-immediately" as SuspensionOutcome;
2351
+ return RESUME_IMMEDIATELY;
1862
2352
  }
1863
2353
  } else {
1864
2354
  let allSettled = true;
2355
+
1865
2356
  for (const child of validated.reason.children) {
1866
2357
  const childRow = yield* readSubmission(operation, child.childSubmissionId);
2358
+
1867
2359
  if (Option.isNone(childRow) || childRow.value.state !== "settled") {
1868
2360
  allSettled = false;
1869
2361
  break;
1870
2362
  }
1871
2363
  }
1872
2364
  if (allSettled) {
1873
- return "resume-immediately" as SuspensionOutcome;
2365
+ return RESUME_IMMEDIATELY;
1874
2366
  }
1875
2367
  }
1876
2368
  const now = yield* currentInstant;
2369
+
1877
2370
  yield* sql`
1878
2371
  UPDATE effect_agent_submissions
1879
2372
  SET
@@ -1888,10 +2381,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1888
2381
  DELETE FROM effect_agent_submission_ownership
1889
2382
  WHERE submission_id = ${validated.submissionId}
1890
2383
  `.pipe(Effect.mapError(sqlFailure(operation)));
1891
- return "suspended" as SuspensionOutcome;
2384
+
2385
+ return SUSPENDED;
1892
2386
  }),
1893
2387
  );
2388
+
1894
2389
  yield* hitFailpoint("ledger:suspend:after", operation);
2390
+
1895
2391
  return outcome;
1896
2392
  });
1897
2393
 
@@ -1904,6 +2400,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1904
2400
  const wakeSuspendedIfCovered = Effect.fn("SqliteSubmissionLedger.wakeSuspendedIfCovered")(
1905
2401
  function* (operation: string, submission: SubmissionRow): Effect.fn.Return<void, LedgerError> {
1906
2402
  if (submission.state !== "suspended" || submission.suspended_reason_json === null) return;
2403
+
1907
2404
  const reason = yield* Schema.decodeEffect(Schema.fromJsonString(SuspensionReason))(
1908
2405
  submission.suspended_reason_json,
1909
2406
  ).pipe(
@@ -1916,9 +2413,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1916
2413
  ),
1917
2414
  ),
1918
2415
  );
2416
+
1919
2417
  if (reason._tag !== "ApprovalPending") return;
1920
2418
  const decisions = yield* readApprovalDecisions(operation, submission.submission_id);
1921
2419
  const decided = new Set(decisions.map((row) => row.tool_call_id));
2420
+
1922
2421
  if (!reason.toolCallIds.every((toolCallId) => decided.has(toolCallId))) return;
1923
2422
  yield* sql`
1924
2423
  UPDATE effect_agent_submissions
@@ -1935,14 +2434,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1935
2434
  "SqliteSubmissionLedger.recordApprovalDecision",
1936
2435
  )(function* (command: ApprovalDecisionCommand) {
1937
2436
  const operation = "ledger record approval decision";
1938
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ApprovalDecisionCommand))(
2437
+
2438
+ const validated = yield* Schema.decodeEffect(Schema.toType(ApprovalDecisionCommand))(
1939
2439
  command,
1940
2440
  ).pipe(Effect.mapError(internalFailure(operation)));
2441
+
1941
2442
  yield* hitFailpoint("ledger:approval-decision:before", operation);
2443
+
1942
2444
  const intent = yield* inWriteTransaction(
1943
2445
  operation,
1944
2446
  Effect.gen(function* () {
1945
2447
  const submission = yield* requireSubmission(operation, validated.submissionId);
2448
+
1946
2449
  if (submission.state === "settled") {
1947
2450
  if (submission.settled_outcome === null) {
1948
2451
  return yield* corruptionFailure(
@@ -1952,6 +2455,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1952
2455
  "A settled Submission carries no terminal outcome.",
1953
2456
  );
1954
2457
  }
2458
+
1955
2459
  return yield* SettlementConflict.make({
1956
2460
  submissionId: validated.submissionId,
1957
2461
  existingOutcome: submission.settled_outcome,
@@ -1959,6 +2463,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1959
2463
  }
1960
2464
  const decisions = yield* readApprovalDecisions(operation, validated.submissionId);
1961
2465
  const existing = decisions.find((row) => row.tool_call_id === validated.toolCallId);
2466
+
1962
2467
  if (existing !== undefined) {
1963
2468
  // Idempotent per (submissionId, toolCallId): repeating the SAME decision replays
1964
2469
  // the recorded intent unchanged; a divergent re-decision conflicts.
@@ -1969,9 +2474,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1969
2474
  existingDecision: existing.decision,
1970
2475
  });
1971
2476
  }
2477
+
1972
2478
  return yield* approvalIntentFromRow(operation, existing);
1973
2479
  }
1974
2480
  const now = yield* currentInstant;
2481
+
1975
2482
  yield* sql`
1976
2483
  INSERT INTO effect_agent_approval_decisions (
1977
2484
  submission_id,
@@ -1990,6 +2497,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
1990
2497
  )
1991
2498
  `.pipe(Effect.mapError(sqlFailure(operation)));
1992
2499
  yield* wakeSuspendedIfCovered(operation, submission);
2500
+
1993
2501
  return yield* decodeApprovalDecisionIntent({
1994
2502
  submissionId: validated.submissionId,
1995
2503
  toolCallId: validated.toolCallId,
@@ -2000,7 +2508,9 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2000
2508
  }).pipe(Effect.mapError(internalFailure(operation)));
2001
2509
  }),
2002
2510
  );
2511
+
2003
2512
  yield* hitFailpoint("ledger:approval-decision:after", operation);
2513
+
2004
2514
  return intent;
2005
2515
  });
2006
2516
 
@@ -2008,14 +2518,17 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2008
2518
  "SqliteSubmissionLedger.markUnknown",
2009
2519
  )(function* (request: MarkUnknownRequest) {
2010
2520
  const operation = "ledger mark unknown";
2011
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(MarkUnknownRequest))(
2012
- request,
2013
- ).pipe(Effect.mapError(internalFailure(operation)));
2521
+
2522
+ const validated = yield* Schema.decodeEffect(Schema.toType(MarkUnknownRequest))(request).pipe(
2523
+ Effect.mapError(internalFailure(operation)),
2524
+ );
2525
+
2014
2526
  yield* hitFailpoint("ledger:mark-unknown:before", operation);
2015
2527
  yield* inWriteTransaction(
2016
2528
  operation,
2017
2529
  Effect.gen(function* () {
2018
2530
  const submission = yield* requireSubmission(operation, validated.submissionId);
2531
+
2019
2532
  if (submission.state === "settled") {
2020
2533
  if (submission.settled_outcome === null) {
2021
2534
  return yield* corruptionFailure(
@@ -2025,6 +2538,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2025
2538
  "A settled Submission carries no terminal outcome.",
2026
2539
  );
2027
2540
  }
2541
+
2028
2542
  return yield* SettlementConflict.make({
2029
2543
  submissionId: validated.submissionId,
2030
2544
  existingOutcome: submission.settled_outcome,
@@ -2033,6 +2547,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2033
2547
  // A reserved exact outcome wins over a late Unknown marking (DUR-011); the recovery
2034
2548
  // classifier orders reservation ahead of MarkUnknown for the same reason.
2035
2549
  const reservation = yield* readReservation(operation, validated.submissionId);
2550
+
2036
2551
  if (Option.isSome(reservation)) {
2037
2552
  return yield* SettlementConflict.make({
2038
2553
  submissionId: validated.submissionId,
@@ -2043,13 +2558,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2043
2558
  // set while the first recorded reason is kept.
2044
2559
  const existingIds = yield* storedUnknownToolCallIds(operation, submission);
2045
2560
  const known = new Set(existingIds);
2561
+
2046
2562
  const merged = [
2047
2563
  ...existingIds,
2048
2564
  ...validated.toolCallIds.filter((toolCallId) => !known.has(toolCallId)),
2049
2565
  ];
2566
+
2050
2567
  const idsJson = yield* encodeToolCallIdsText(merged).pipe(
2051
2568
  Effect.mapError(internalFailure(operation)),
2052
2569
  );
2570
+
2053
2571
  yield* sql`
2054
2572
  UPDATE effect_agent_submissions
2055
2573
  SET
@@ -2067,17 +2585,22 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2067
2585
  "SqliteSubmissionLedger.recordUnknownResolution",
2068
2586
  )(function* (command: UnknownResolutionCommand) {
2069
2587
  const operation = "ledger record unknown resolution";
2070
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(UnknownResolutionCommand))(
2588
+
2589
+ const validated = yield* Schema.decodeEffect(Schema.toType(UnknownResolutionCommand))(
2071
2590
  command,
2072
2591
  ).pipe(Effect.mapError(internalFailure(operation)));
2592
+
2073
2593
  const resolutionJson = yield* encodeUnknownResolutionText(validated.resolution).pipe(
2074
2594
  Effect.mapError(internalFailure(operation)),
2075
2595
  );
2596
+
2076
2597
  yield* hitFailpoint("ledger:unknown-resolution:before", operation);
2598
+
2077
2599
  const intent = yield* inWriteTransaction(
2078
2600
  operation,
2079
2601
  Effect.gen(function* () {
2080
2602
  const submission = yield* requireSubmission(operation, validated.submissionId);
2603
+
2081
2604
  if (submission.state === "settled") {
2082
2605
  if (submission.settled_outcome === null) {
2083
2606
  return yield* corruptionFailure(
@@ -2087,6 +2610,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2087
2610
  "A settled Submission carries no terminal outcome.",
2088
2611
  );
2089
2612
  }
2613
+
2090
2614
  return yield* SettlementConflict.make({
2091
2615
  submissionId: validated.submissionId,
2092
2616
  existingOutcome: submission.settled_outcome,
@@ -2094,19 +2618,30 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2094
2618
  }
2095
2619
  const resolutions = yield* readUnknownResolutions(operation, validated.submissionId);
2096
2620
  const existing = resolutions.find((row) => row.tool_call_id === validated.toolCallId);
2097
- if (existing !== undefined && existing.resolution_json !== resolutionJson) {
2621
+
2622
+ const existingIntent =
2623
+ existing === undefined
2624
+ ? undefined
2625
+ : yield* unknownResolutionIntentFromRow(operation, existing);
2626
+
2627
+ if (
2628
+ existingIntent !== undefined &&
2629
+ !equivalentUnknownResolution(existingIntent.resolution, validated.resolution)
2630
+ ) {
2098
2631
  return yield* UnknownResolutionConflict.make({
2099
2632
  submissionId: validated.submissionId,
2100
2633
  toolCallId: validated.toolCallId,
2101
2634
  });
2102
2635
  }
2103
2636
  let resolved: UnknownResolutionIntent;
2104
- if (existing !== undefined) {
2637
+
2638
+ if (existingIntent !== undefined) {
2105
2639
  // Idempotent replay of the recorded intent (author/reason may differ; the stored
2106
2640
  // audit fields win, exactly like requestAbort).
2107
- resolved = yield* unknownResolutionIntentFromRow(operation, existing);
2641
+ resolved = existingIntent;
2108
2642
  } else {
2109
2643
  const now = yield* currentInstant;
2644
+
2110
2645
  yield* sql`
2111
2646
  INSERT INTO effect_agent_unknown_resolutions (
2112
2647
  submission_id,
@@ -2124,9 +2659,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2124
2659
  ${now.iso}
2125
2660
  )
2126
2661
  `.pipe(Effect.mapError(sqlFailure(operation)));
2662
+
2127
2663
  const resolution = yield* parseStoredJsonText(resolutionJson).pipe(
2128
2664
  Effect.mapError(internalFailure(operation)),
2129
2665
  );
2666
+
2130
2667
  resolved = yield* decodeUnknownResolutionIntent({
2131
2668
  submissionId: validated.submissionId,
2132
2669
  toolCallId: validated.toolCallId,
@@ -2143,6 +2680,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2143
2680
  const markedIds = yield* storedUnknownToolCallIds(operation, submission);
2144
2681
  const covering = yield* readUnknownResolutions(operation, validated.submissionId);
2145
2682
  const coveredIds = new Set(covering.map((row) => row.tool_call_id));
2683
+
2146
2684
  if (markedIds.every((toolCallId) => coveredIds.has(toolCallId))) {
2147
2685
  yield* sql`
2148
2686
  UPDATE effect_agent_submissions
@@ -2154,10 +2692,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2154
2692
  `.pipe(Effect.mapError(sqlFailure(operation)));
2155
2693
  }
2156
2694
  }
2695
+
2157
2696
  return resolved;
2158
2697
  }),
2159
2698
  );
2699
+
2160
2700
  yield* hitFailpoint("ledger:unknown-resolution:after", operation);
2701
+
2161
2702
  return intent;
2162
2703
  });
2163
2704
 
@@ -2165,26 +2706,38 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2165
2706
  "SqliteSubmissionLedger.recordChildSettled",
2166
2707
  )(function* (request: ChildSettledNotification) {
2167
2708
  const operation = "ledger record child settled";
2168
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ChildSettledNotification))(
2709
+
2710
+ const validated = yield* Schema.decodeEffect(Schema.toType(ChildSettledNotification))(
2169
2711
  request,
2170
2712
  ).pipe(Effect.mapError(internalFailure(operation)));
2713
+
2171
2714
  yield* hitFailpoint("ledger:child-settled:before", operation);
2715
+
2172
2716
  const outcome = yield* inWriteTransaction(
2173
2717
  operation,
2174
2718
  Effect.gen(function* () {
2175
2719
  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.
2720
+ // The caller may notify after the canonical Settlement append but before ledger
2721
+ // finalization. An exact reservation plus `terminalizing` is the narrow durable prefix
2722
+ // that makes that ordering admissible; earlier states remain a caller error.
2178
2723
  const child = yield* readSubmission(operation, validated.childSubmissionId);
2179
- if (Option.isNone(child) || child.value.state !== "settled") {
2724
+ const childReservation = yield* readReservation(operation, validated.childSubmissionId);
2725
+
2726
+ const announced =
2727
+ Option.isSome(child) &&
2728
+ (child.value.state === "settled" ||
2729
+ (child.value.state === "terminalizing" && Option.isSome(childReservation)));
2730
+
2731
+ if (!announced) {
2180
2732
  return yield* LedgerError.make({
2181
2733
  operation,
2182
2734
  message: `Child submission ${validated.childSubmissionId} has no recorded settlement.`,
2183
2735
  });
2184
2736
  }
2185
2737
  if (parent.state !== "suspended" || parent.suspended_reason_json === null) {
2186
- return "not-waiting" as ChildSettledOutcome;
2738
+ return NOT_WAITING;
2187
2739
  }
2740
+
2188
2741
  const reason = yield* Schema.decodeEffect(Schema.fromJsonString(SuspensionReason))(
2189
2742
  parent.suspended_reason_json,
2190
2743
  ).pipe(
@@ -2197,20 +2750,28 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2197
2750
  ),
2198
2751
  ),
2199
2752
  );
2753
+
2200
2754
  if (reason._tag !== "WaitingForChild") {
2201
- return "not-waiting" as ChildSettledOutcome;
2755
+ return NOT_WAITING;
2202
2756
  }
2203
2757
  if (
2204
2758
  !reason.children.some((entry) => entry.childSubmissionId === validated.childSubmissionId)
2205
2759
  ) {
2206
- return "not-waiting" as ChildSettledOutcome;
2760
+ return NOT_WAITING;
2207
2761
  }
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.
2762
+ // Every listed child must be either finalized or canonically announced from the exact
2763
+ // terminalizing reservation. Replays re-run this coverage check idempotently.
2210
2764
  for (const entry of reason.children) {
2211
2765
  const listed = yield* readSubmission(operation, entry.childSubmissionId);
2212
- if (Option.isNone(listed) || listed.value.state !== "settled") {
2213
- return "still-waiting" as ChildSettledOutcome;
2766
+ const reservation = yield* readReservation(operation, entry.childSubmissionId);
2767
+
2768
+ const covered =
2769
+ Option.isSome(listed) &&
2770
+ (listed.value.state === "settled" ||
2771
+ (listed.value.state === "terminalizing" && Option.isSome(reservation)));
2772
+
2773
+ if (!covered) {
2774
+ return STILL_WAITING;
2214
2775
  }
2215
2776
  }
2216
2777
  yield* sql`
@@ -2221,10 +2782,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2221
2782
  suspended_at = NULL
2222
2783
  WHERE submission_id = ${validated.parentSubmissionId}
2223
2784
  `.pipe(Effect.mapError(sqlFailure(operation)));
2224
- return "woken" as ChildSettledOutcome;
2785
+
2786
+ return WOKEN;
2225
2787
  }),
2226
2788
  );
2789
+
2227
2790
  yield* hitFailpoint("ledger:child-settled:after", operation);
2791
+
2228
2792
  return outcome;
2229
2793
  });
2230
2794
 
@@ -2232,25 +2796,36 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2232
2796
  "SqliteSubmissionLedger.reserveChildBudget",
2233
2797
  )(function* (request: ChildBudgetReservationRequest) {
2234
2798
  const operation = "ledger reserve child budget";
2235
- const validated = yield* Schema.decodeUnknownEffect(
2236
- Schema.toType(ChildBudgetReservationRequest),
2237
- )(request).pipe(Effect.mapError(internalFailure(operation)));
2799
+
2800
+ const validated = yield* Schema.decodeEffect(Schema.toType(ChildBudgetReservationRequest))(
2801
+ request,
2802
+ ).pipe(Effect.mapError(internalFailure(operation)));
2803
+
2238
2804
  const allocationJson = yield* encodePersistedJsonText(validated.allocation).pipe(
2239
2805
  Effect.mapError(internalFailure(operation)),
2240
2806
  );
2807
+
2241
2808
  yield* hitFailpoint("ledger:child-reservation:before", operation);
2809
+
2242
2810
  const reserved = yield* inWriteTransaction(
2243
2811
  operation,
2244
2812
  Effect.gen(function* () {
2245
2813
  const existing = yield* readChildReservation(operation, validated.reservationId);
2814
+
2246
2815
  if (Option.isSome(existing)) {
2816
+ const existingSnapshot = yield* childReservationSnapshotFromRow(
2817
+ operation,
2818
+ existing.value,
2819
+ );
2820
+
2247
2821
  // Identical replays short-circuit before the fence, mirroring reserveSettlement: a
2248
2822
  // replay creates nothing, so a recovering caller resumes rather than duplicates.
2249
2823
  const identical =
2250
2824
  existing.value.parent_submission_id === validated.parentSubmissionId &&
2251
2825
  existing.value.parent_tool_call_id === validated.parentToolCallId &&
2252
2826
  existing.value.allocation_digest === validated.allocationDigest &&
2253
- existing.value.allocation_json === allocationJson;
2827
+ equivalentPersistedJson(existingSnapshot.allocation, validated.allocation);
2828
+
2254
2829
  if (!identical) {
2255
2830
  return yield* ChildReservationConflict.make({
2256
2831
  reservationId: validated.reservationId,
@@ -2259,16 +2834,19 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2259
2834
  "A reservation with this identity exists with a different parent Tool Call or allocation.",
2260
2835
  });
2261
2836
  }
2837
+
2262
2838
  return ReservedChildBudget.make({
2263
- reservation: yield* childReservationSnapshotFromRow(operation, existing.value),
2839
+ reservation: existingSnapshot,
2264
2840
  replayed: true,
2265
2841
  });
2266
2842
  }
2843
+
2267
2844
  const collision = yield* readChildReservationForCall(
2268
2845
  operation,
2269
2846
  validated.parentSubmissionId,
2270
2847
  validated.parentToolCallId,
2271
2848
  );
2849
+
2272
2850
  if (Option.isSome(collision)) {
2273
2851
  return yield* ChildReservationConflict.make({
2274
2852
  reservationId: validated.reservationId,
@@ -2277,10 +2855,12 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2277
2855
  });
2278
2856
  }
2279
2857
  const parent = yield* requireSubmission(operation, validated.parentSubmissionId);
2858
+
2280
2859
  // Creation is fenced by the parent lane's live ownership (spec §12 step 2): a stale
2281
2860
  // parent Attempt can never create new reservation state.
2282
2861
  yield* requireOwnership(operation, parent, validated.ownershipToken);
2283
2862
  const now = yield* currentInstant;
2863
+
2284
2864
  yield* sql`
2285
2865
  INSERT INTO effect_agent_child_reservations (
2286
2866
  reservation_id,
@@ -2301,6 +2881,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2301
2881
  )
2302
2882
  `.pipe(Effect.mapError(sqlFailure(operation)));
2303
2883
  const inserted = yield* readChildReservation(operation, validated.reservationId);
2884
+
2304
2885
  if (Option.isNone(inserted)) {
2305
2886
  return yield* corruptionFailure(
2306
2887
  operation,
@@ -2309,13 +2890,16 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2309
2890
  "An inserted child reservation row is missing inside its own transaction.",
2310
2891
  );
2311
2892
  }
2893
+
2312
2894
  return ReservedChildBudget.make({
2313
2895
  reservation: yield* childReservationSnapshotFromRow(operation, inserted.value),
2314
2896
  replayed: false,
2315
2897
  });
2316
2898
  }),
2317
2899
  );
2900
+
2318
2901
  yield* hitFailpoint("ledger:child-reservation:after", operation);
2902
+
2319
2903
  return reserved;
2320
2904
  });
2321
2905
 
@@ -2324,14 +2908,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2324
2908
  request: AttachChildToReservationRequest,
2325
2909
  ) {
2326
2910
  const operation = "ledger attach child to reservation";
2327
- const validated = yield* Schema.decodeUnknownEffect(
2328
- Schema.toType(AttachChildToReservationRequest),
2329
- )(request).pipe(Effect.mapError(internalFailure(operation)));
2911
+
2912
+ const validated = yield* Schema.decodeEffect(Schema.toType(AttachChildToReservationRequest))(
2913
+ request,
2914
+ ).pipe(Effect.mapError(internalFailure(operation)));
2915
+
2330
2916
  yield* hitFailpoint("ledger:child-attach:before", operation);
2917
+
2331
2918
  const attached = yield* inWriteTransaction(
2332
2919
  operation,
2333
2920
  Effect.gen(function* () {
2334
2921
  const existing = yield* readChildReservation(operation, validated.reservationId);
2922
+
2335
2923
  if (Option.isNone(existing)) {
2336
2924
  return yield* LedgerError.make({
2337
2925
  operation,
@@ -2343,6 +2931,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2343
2931
  if (existing.value.child_submission_id === validated.childSubmissionId) {
2344
2932
  return yield* childReservationSnapshotFromRow(operation, existing.value);
2345
2933
  }
2934
+
2346
2935
  return yield* ChildReservationConflict.make({
2347
2936
  reservationId: validated.reservationId,
2348
2937
  status: existing.value.status,
@@ -2350,6 +2939,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2350
2939
  });
2351
2940
  }
2352
2941
  const parent = yield* requireSubmission(operation, existing.value.parent_submission_id);
2942
+
2353
2943
  yield* requireOwnership(operation, parent, validated.ownershipToken);
2354
2944
  if (existing.value.status !== "reserved") {
2355
2945
  return yield* ChildReservationConflict.make({
@@ -2361,6 +2951,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2361
2951
  // Single-store latitude: the admitted child must exist here, so a dangling
2362
2952
  // attachment can never enter the recovery view.
2363
2953
  const child = yield* readSubmission(operation, validated.childSubmissionId);
2954
+
2364
2955
  if (Option.isNone(child)) {
2365
2956
  return yield* LedgerError.make({
2366
2957
  operation,
@@ -2373,6 +2964,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2373
2964
  WHERE reservation_id = ${validated.reservationId}
2374
2965
  `.pipe(Effect.mapError(sqlFailure(operation)));
2375
2966
  const updated = yield* readChildReservation(operation, validated.reservationId);
2967
+
2376
2968
  if (Option.isNone(updated)) {
2377
2969
  return yield* corruptionFailure(
2378
2970
  operation,
@@ -2381,10 +2973,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2381
2973
  "An updated child reservation row is missing inside its own transaction.",
2382
2974
  );
2383
2975
  }
2976
+
2384
2977
  return yield* childReservationSnapshotFromRow(operation, updated.value);
2385
2978
  }),
2386
2979
  );
2980
+
2387
2981
  yield* hitFailpoint("ledger:child-attach:after", operation);
2982
+
2388
2983
  return attached;
2389
2984
  });
2390
2985
 
@@ -2392,17 +2987,22 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2392
2987
  "SqliteSubmissionLedger.beginChildBudgetRelease",
2393
2988
  )(function* (request: BeginChildBudgetReleaseRequest) {
2394
2989
  const operation = "ledger begin child budget release";
2395
- const validated = yield* Schema.decodeUnknownEffect(
2396
- Schema.toType(BeginChildBudgetReleaseRequest),
2397
- )(request).pipe(Effect.mapError(internalFailure(operation)));
2990
+
2991
+ const validated = yield* Schema.decodeEffect(Schema.toType(BeginChildBudgetReleaseRequest))(
2992
+ request,
2993
+ ).pipe(Effect.mapError(internalFailure(operation)));
2994
+
2398
2995
  const accountingJson = yield* encodePersistedJsonText(validated.accounting).pipe(
2399
2996
  Effect.mapError(internalFailure(operation)),
2400
2997
  );
2998
+
2401
2999
  yield* hitFailpoint("ledger:child-release-pending:before", operation);
3000
+
2402
3001
  const frozen = yield* inWriteTransaction(
2403
3002
  operation,
2404
3003
  Effect.gen(function* () {
2405
3004
  const existing = yield* readChildReservation(operation, validated.reservationId);
3005
+
2406
3006
  if (Option.isNone(existing)) {
2407
3007
  return yield* LedgerError.make({
2408
3008
  operation,
@@ -2410,11 +3010,20 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2410
3010
  });
2411
3011
  }
2412
3012
  if (existing.value.status !== "reserved") {
3013
+ const existingSnapshot = yield* childReservationSnapshotFromRow(
3014
+ operation,
3015
+ existing.value,
3016
+ );
3017
+
2413
3018
  // The accounting decision was already frozen exactly once; an identical replay is a
2414
3019
  // 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);
3020
+ if (
3021
+ existingSnapshot.accounting !== undefined &&
3022
+ equivalentPersistedJson(existingSnapshot.accounting, validated.accounting)
3023
+ ) {
3024
+ return existingSnapshot;
2417
3025
  }
3026
+
2418
3027
  return yield* ChildReservationConflict.make({
2419
3028
  reservationId: validated.reservationId,
2420
3029
  status: existing.value.status,
@@ -2422,6 +3031,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2422
3031
  });
2423
3032
  }
2424
3033
  const now = yield* currentInstant;
3034
+
2425
3035
  yield* sql`
2426
3036
  UPDATE effect_agent_child_reservations
2427
3037
  SET
@@ -2431,6 +3041,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2431
3041
  WHERE reservation_id = ${validated.reservationId}
2432
3042
  `.pipe(Effect.mapError(sqlFailure(operation)));
2433
3043
  const updated = yield* readChildReservation(operation, validated.reservationId);
3044
+
2434
3045
  if (Option.isNone(updated)) {
2435
3046
  return yield* corruptionFailure(
2436
3047
  operation,
@@ -2439,10 +3050,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2439
3050
  "An updated child reservation row is missing inside its own transaction.",
2440
3051
  );
2441
3052
  }
3053
+
2442
3054
  return yield* childReservationSnapshotFromRow(operation, updated.value);
2443
3055
  }),
2444
3056
  );
3057
+
2445
3058
  yield* hitFailpoint("ledger:child-release-pending:after", operation);
3059
+
2446
3060
  return frozen;
2447
3061
  });
2448
3062
 
@@ -2450,14 +3064,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2450
3064
  "SqliteSubmissionLedger.releaseChildBudget",
2451
3065
  )(function* (request: ReleaseChildBudgetRequest) {
2452
3066
  const operation = "ledger release child budget";
2453
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(ReleaseChildBudgetRequest))(
3067
+
3068
+ const validated = yield* Schema.decodeEffect(Schema.toType(ReleaseChildBudgetRequest))(
2454
3069
  request,
2455
3070
  ).pipe(Effect.mapError(internalFailure(operation)));
3071
+
2456
3072
  yield* hitFailpoint("ledger:child-release:before", operation);
3073
+
2457
3074
  const released = yield* inWriteTransaction(
2458
3075
  operation,
2459
3076
  Effect.gen(function* () {
2460
3077
  const existing = yield* readChildReservation(operation, validated.reservationId);
3078
+
2461
3079
  if (Option.isNone(existing)) {
2462
3080
  return yield* LedgerError.make({
2463
3081
  operation,
@@ -2477,12 +3095,14 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2477
3095
  });
2478
3096
  }
2479
3097
  const now = yield* currentInstant;
3098
+
2480
3099
  yield* sql`
2481
3100
  UPDATE effect_agent_child_reservations
2482
3101
  SET status = 'released', released_at = ${now.iso}
2483
3102
  WHERE reservation_id = ${validated.reservationId}
2484
3103
  `.pipe(Effect.mapError(sqlFailure(operation)));
2485
3104
  const updated = yield* readChildReservation(operation, validated.reservationId);
3105
+
2486
3106
  if (Option.isNone(updated)) {
2487
3107
  return yield* corruptionFailure(
2488
3108
  operation,
@@ -2491,15 +3111,18 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2491
3111
  "An updated child reservation row is missing inside its own transaction.",
2492
3112
  );
2493
3113
  }
3114
+
2494
3115
  return yield* childReservationSnapshotFromRow(operation, updated.value);
2495
3116
  }),
2496
3117
  );
3118
+
2497
3119
  yield* hitFailpoint("ledger:child-release:after", operation);
3120
+
2498
3121
  return released;
2499
3122
  });
2500
3123
 
2501
3124
  interface ScanCursor {
2502
- readonly conversationId: string;
3125
+ readonly threadId: string;
2503
3126
  readonly queueSequence: number;
2504
3127
  }
2505
3128
 
@@ -2510,65 +3133,140 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2510
3133
  LedgerError
2511
3134
  > {
2512
3135
  const operation = "ledger scan nonterminal";
3136
+
2513
3137
  const rows = yield* (
2514
3138
  cursor === undefined
2515
3139
  ? sql<Record<string, unknown>>`
2516
3140
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
2517
3141
  FROM effect_agent_submissions
2518
3142
  WHERE state <> 'settled'
2519
- ORDER BY conversation_id ASC, queue_sequence ASC
3143
+ ORDER BY thread_id ASC, queue_sequence ASC
2520
3144
  LIMIT ${SCAN_PAGE_SIZE}
2521
3145
  `
2522
3146
  : sql<Record<string, unknown>>`
2523
3147
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
2524
3148
  FROM effect_agent_submissions
2525
3149
  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
3150
+ AND (thread_id, queue_sequence) > (${cursor.threadId}, ${cursor.queueSequence})
3151
+ ORDER BY thread_id ASC, queue_sequence ASC
2534
3152
  LIMIT ${SCAN_PAGE_SIZE}
2535
3153
  `
2536
3154
  ).pipe(Effect.mapError(sqlFailure(operation)));
3155
+
2537
3156
  const decoded = yield* decodeSubmissionRows(operation, "nonterminal_scan", rows);
3157
+
2538
3158
  const snapshots = yield* Effect.forEach(decoded, (row) =>
2539
3159
  decodeSubmissionSnapshot(operation, row),
2540
3160
  );
3161
+
2541
3162
  const last = decoded[decoded.length - 1];
3163
+
2542
3164
  const next: Option.Option<ScanCursor | undefined> =
2543
3165
  last === undefined || decoded.length < SCAN_PAGE_SIZE
2544
3166
  ? Option.none()
2545
3167
  : Option.some({
2546
- conversationId: last.conversation_id,
3168
+ threadId: last.thread_id,
2547
3169
  queueSequence: last.queue_sequence,
2548
3170
  });
3171
+
2549
3172
  return [snapshots, next] as const;
2550
3173
  });
2551
3174
 
2552
- const scanNonterminal: Stream.Stream<SubmissionSnapshot, LedgerError> = Stream.paginate(
2553
- undefined as ScanCursor | undefined,
2554
- scanPage,
2555
- );
3175
+ const scanNonterminal: Stream.Stream<SubmissionSnapshot, LedgerError> = Stream.paginate<
3176
+ ScanCursor | undefined,
3177
+ SubmissionSnapshot,
3178
+ LedgerError
3179
+ >(undefined, scanPage);
3180
+
3181
+ const readAbortIntentForSubmission: SubmissionLedger["Service"]["readAbortIntent"] = Effect.fn(
3182
+ "SqliteSubmissionLedger.readAbortIntentForSubmission",
3183
+ )(function* (request) {
3184
+ const operation = "ledger read abort intent";
3185
+
3186
+ const validated = yield* Schema.decodeEffect(Schema.toType(AbortIntentRequest))(request).pipe(
3187
+ Effect.mapError(internalFailure(operation)),
3188
+ );
3189
+
3190
+ const recordId = submissionAbortRecordId(validated.submissionId);
3191
+
3192
+ const rows = yield* sql<Record<string, unknown>>`
3193
+ SELECT
3194
+ submission.submission_id,
3195
+ abort.submission_id AS abort_submission_id,
3196
+ abort.author,
3197
+ abort.reason,
3198
+ abort.requested_at,
3199
+ canonical.record_id AS canonical_record_id
3200
+ FROM effect_agent_submissions AS submission
3201
+ LEFT JOIN effect_agent_abort_intents AS abort
3202
+ ON abort.submission_id = submission.submission_id
3203
+ LEFT JOIN effect_agent_canonical_records AS canonical
3204
+ ON canonical.thread_id = submission.thread_id
3205
+ AND canonical.record_id = ${recordId}
3206
+ WHERE submission.submission_id = ${validated.submissionId}
3207
+ `.pipe(Effect.mapError(sqlFailure(operation)));
3208
+
3209
+ const decoded = yield* decodeRows(
3210
+ Schema.Array(AbortIntentLookupRow),
3211
+ "effect_agent_abort_intents",
3212
+ validated.submissionId,
3213
+ rows,
3214
+ ).pipe(Effect.mapError(internalFailure(operation)));
3215
+
3216
+ if (decoded.length === 0) {
3217
+ return yield* LedgerError.make({
3218
+ operation,
3219
+ message: `Unknown submission ${validated.submissionId}.`,
3220
+ });
3221
+ }
3222
+ if (decoded.length !== 1) {
3223
+ return yield* corruptionFailure(
3224
+ operation,
3225
+ "effect_agent_abort_intents",
3226
+ validated.submissionId,
3227
+ "An abort intent lookup returned more than one row.",
3228
+ );
3229
+ }
3230
+ const row = decoded[0];
3231
+
3232
+ if (row.abort_submission_id === null) return undefined;
3233
+
3234
+ return yield* decodeAbortIntent({
3235
+ submissionId: row.abort_submission_id,
3236
+ author: row.author,
3237
+ reason: row.reason,
3238
+ requestedAt: row.requested_at,
3239
+ ...(row.canonical_record_id === null ? {} : { canonicalRecordId: row.canonical_record_id }),
3240
+ }).pipe(
3241
+ Effect.mapError((error) =>
3242
+ corruptionFailure(
3243
+ operation,
3244
+ "effect_agent_abort_intents",
3245
+ validated.submissionId,
3246
+ error.message,
3247
+ ),
3248
+ ),
3249
+ );
3250
+ });
2556
3251
 
2557
3252
  const loadRecoverySnapshot: SubmissionLedger["Service"]["loadRecoverySnapshot"] = Effect.fn(
2558
3253
  "SqliteSubmissionLedger.loadRecoverySnapshot",
2559
3254
  )(function* (request: RecoverySnapshotRequest) {
2560
3255
  const operation = "ledger load recovery snapshot";
2561
- const validated = yield* Schema.decodeUnknownEffect(Schema.toType(RecoverySnapshotRequest))(
3256
+
3257
+ const validated = yield* Schema.decodeEffect(Schema.toType(RecoverySnapshotRequest))(
2562
3258
  request,
2563
3259
  ).pipe(Effect.mapError(internalFailure(operation)));
2564
- return yield* sql
2565
- .withTransaction(
3260
+
3261
+ return yield* journal
3262
+ .withReadTransaction(operation)(
2566
3263
  Effect.gen(function* () {
2567
3264
  const submissionRow = yield* requireSubmission(operation, validated.submissionId);
2568
3265
  const submission = yield* decodeSubmissionSnapshot(operation, submissionRow);
2569
3266
 
2570
3267
  let ownership: OwnershipSnapshot | undefined;
2571
3268
  const ownershipRow = yield* readOwnership(operation, validated.submissionId);
3269
+
2572
3270
  if (Option.isSome(ownershipRow)) {
2573
3271
  ownership = yield* decodeOwnershipSnapshot({
2574
3272
  attemptId: ownershipRow.value.attempt_id,
@@ -2579,6 +3277,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2579
3277
  }
2580
3278
 
2581
3279
  let inputApplied: InputAppliedMarker | undefined;
3280
+
2582
3281
  if (
2583
3282
  submissionRow.input_applied_record_id !== null &&
2584
3283
  submissionRow.input_applied_sequence !== null
@@ -2591,6 +3290,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2591
3290
 
2592
3291
  let reservation: SettlementReservationSnapshot | undefined;
2593
3292
  const reservationRow = yield* readReservation(operation, validated.submissionId);
3293
+
2594
3294
  if (Option.isSome(reservationRow)) {
2595
3295
  const record = yield* decodeRecordEnvelopeText(reservationRow.value.record_json).pipe(
2596
3296
  Effect.mapError((error) =>
@@ -2602,9 +3302,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2602
3302
  ),
2603
3303
  ),
2604
3304
  );
2605
- const settlementId = yield* Schema.decodeUnknownEffect(
3305
+
3306
+ const settlementId = yield* Schema.decodeEffect(
2606
3307
  SettlementReservationSnapshot.fields.settlementId,
2607
3308
  )(reservationRow.value.settlement_id).pipe(Effect.mapError(internalFailure(operation)));
3309
+
2608
3310
  reservation = SettlementReservationSnapshot.make({
2609
3311
  settlementId,
2610
3312
  outcome: reservationRow.value.outcome,
@@ -2616,6 +3318,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2616
3318
 
2617
3319
  let abortIntent: AbortIntent | undefined;
2618
3320
  const abortRow = yield* readAbortIntent(operation, validated.submissionId);
3321
+
2619
3322
  if (Option.isSome(abortRow)) {
2620
3323
  abortIntent = yield* abortIntentFromRow(
2621
3324
  operation,
@@ -2633,11 +3336,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2633
3336
  WHERE joined_host_submission_id = ${validated.submissionId}
2634
3337
  ORDER BY queue_sequence ASC
2635
3338
  `.pipe(Effect.mapError(sqlFailure(operation)));
3339
+
2636
3340
  const joinSubmissions = yield* decodeSubmissionRows(
2637
3341
  operation,
2638
3342
  validated.submissionId,
2639
3343
  joinRows,
2640
3344
  );
3345
+
2641
3346
  const joins = yield* Effect.forEach(joinSubmissions, (row) =>
2642
3347
  decodeJoinSnapshot({
2643
3348
  submissionId: row.submission_id,
@@ -2647,6 +3352,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2647
3352
  );
2648
3353
 
2649
3354
  let hostSubmissionId: RecoverySnapshot["hostSubmissionId"];
3355
+
2650
3356
  if (submissionRow.joined_host_submission_id !== null) {
2651
3357
  hostSubmissionId = yield* decodeSubmissionId(
2652
3358
  submissionRow.joined_host_submission_id,
@@ -2654,6 +3360,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2654
3360
  }
2655
3361
 
2656
3362
  let suspension: SuspensionSnapshot | undefined;
3363
+
2657
3364
  if (submissionRow.suspended_reason_json !== null && submissionRow.suspended_at !== null) {
2658
3365
  const reason = yield* parseStoredJsonText(submissionRow.suspended_reason_json).pipe(
2659
3366
  Effect.mapError((error) =>
@@ -2665,6 +3372,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2665
3372
  ),
2666
3373
  ),
2667
3374
  );
3375
+
2668
3376
  suspension = yield* decodeSuspensionSnapshot({
2669
3377
  reason,
2670
3378
  suspendedAt: submissionRow.suspended_at,
@@ -2681,11 +3389,13 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2681
3389
  }
2682
3390
 
2683
3391
  const decisionRows = yield* readApprovalDecisions(operation, validated.submissionId);
3392
+
2684
3393
  const approvalDecisions = yield* Effect.forEach(decisionRows, (row) =>
2685
3394
  approvalIntentFromRow(operation, row),
2686
3395
  );
2687
3396
 
2688
3397
  const resolutionRows = yield* readUnknownResolutions(operation, validated.submissionId);
3398
+
2689
3399
  const unknownResolutions = yield* Effect.forEach(resolutionRows, (row) =>
2690
3400
  unknownResolutionIntentFromRow(operation, row),
2691
3401
  );
@@ -2699,18 +3409,23 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2699
3409
  WHERE parent_submission_id = ${validated.submissionId}
2700
3410
  ORDER BY parent_tool_call_id ASC
2701
3411
  `.pipe(Effect.mapError(sqlFailure(operation)));
3412
+
2702
3413
  const decodedChildReservations = yield* decodeChildReservationRows(
2703
3414
  operation,
2704
3415
  validated.submissionId,
2705
3416
  childReservationRows,
2706
3417
  );
3418
+
2707
3419
  const childReservations = yield* Effect.forEach(decodedChildReservations, (row) =>
2708
3420
  childReservationSnapshotFromRow(operation, row),
2709
3421
  );
3422
+
2710
3423
  const childAttachments: Array<ChildAttachmentSnapshot> = [];
3424
+
2711
3425
  for (const row of decodedChildReservations) {
2712
3426
  if (row.child_submission_id === null) continue;
2713
3427
  const child = yield* readSubmission(operation, row.child_submission_id);
3428
+
2714
3429
  if (Option.isNone(child)) continue;
2715
3430
  childAttachments.push(
2716
3431
  yield* decodeChildAttachmentSnapshot({
@@ -2725,6 +3440,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2725
3440
  }
2726
3441
 
2727
3442
  let parentLinkage: ParentLinkage | undefined;
3443
+
2728
3444
  if (
2729
3445
  submissionRow.parent_submission_id !== null &&
2730
3446
  submissionRow.parent_tool_call_id !== null
@@ -2752,7 +3468,11 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2752
3468
  });
2753
3469
  }),
2754
3470
  )
2755
- .pipe(Effect.catchTag("SqlError", (error) => Effect.fail(sqlFailure(operation)(error))));
3471
+ .pipe(
3472
+ Effect.catchTag("SqliteStorageError", (error) =>
3473
+ Effect.fail(internalFailure(operation)(error)),
3474
+ ),
3475
+ );
2756
3476
  });
2757
3477
 
2758
3478
  return Context.make(
@@ -2784,6 +3504,7 @@ const makeServices = Effect.fn("SqliteSubmissionLedger.makeServices")(function*
2784
3504
  releaseChildBudget,
2785
3505
  scanNonterminal,
2786
3506
  loadRecoverySnapshot,
3507
+ readAbortIntent: readAbortIntentForSubmission,
2787
3508
  }),
2788
3509
  );
2789
3510
  });
@@ -2801,18 +3522,22 @@ export const submissionLedgerLayer: Layer.Layer<
2801
3522
 
2802
3523
  /**
2803
3524
  * 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.
3525
+ * same database file as the ThreadStore so claims fence the same producer epochs.
2805
3526
  */
2806
3527
  export const ledgerLayer = (
2807
3528
  options: SqliteStorageOptions,
2808
3529
  ): 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,
3530
+ Layer.unwrap(
3531
+ Effect.map(SqliteStorageConfig, (config) =>
3532
+ submissionLedgerLayer.pipe(
3533
+ Layer.provide(
3534
+ Layer.mergeAll(
3535
+ Layer.succeed(SqliteStorageConfig)(config),
3536
+ storageFailpointLayer(options),
3537
+ SqliteClient.layer({ filename: options.filename }),
3538
+ NodeCrypto.layer,
3539
+ ),
3540
+ ),
2816
3541
  ),
2817
3542
  ),
2818
- );
3543
+ ).pipe(Layer.provide(storageConfigLayer(options)));