@effect-agent/storage-cloudflare 0.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routing.ts ADDED
@@ -0,0 +1,1083 @@
1
+ import {
2
+ AdmissionIndeterminate,
3
+ AppendConflict,
4
+ ChildAttachmentSnapshot,
5
+ ConversationMaterialization,
6
+ ConversationNotMaterialized,
7
+ ConversationStore,
8
+ ConversationStoreError,
9
+ FenceRejected,
10
+ JoinedToHost,
11
+ LedgerError,
12
+ RecoverySnapshot,
13
+ SettlementConflict,
14
+ SubmissionLedger,
15
+ SubmissionLookupById,
16
+ type AdmissionConflict,
17
+ type SubmissionLookupByKey,
18
+ type SubmissionSnapshot,
19
+ } from "@effect-agent/session";
20
+ import { Context, Effect, Layer, Option, Schema, Stream } from "effect";
21
+
22
+ import {
23
+ boundPortDiagnostic,
24
+ decodePortRequest,
25
+ decodePortResponse,
26
+ encodePortRequest,
27
+ encodePortResponse,
28
+ LedgerAdmitCall,
29
+ LedgerAdmitResult,
30
+ LedgerLookupCall,
31
+ LedgerLookupResult,
32
+ LedgerMarkReadyCall,
33
+ LedgerMarkReadyResult,
34
+ LedgerRecordChildSettledCall,
35
+ LedgerRecordChildSettledResult,
36
+ LedgerRequestAbortCall,
37
+ LedgerRequestAbortResult,
38
+ LedgerResolveAdmissionCall,
39
+ LedgerResolveAdmissionResult,
40
+ PortFailed,
41
+ PortProtocolError,
42
+ PortSucceeded,
43
+ StoreAppendCall,
44
+ StoreAppendResult,
45
+ StoreExportCall,
46
+ StoreExportResult,
47
+ StoreInspectTailCall,
48
+ StoreInspectTailResult,
49
+ StoreMaterializeCall,
50
+ StoreMaterializeResult,
51
+ StoreReadPageCall,
52
+ StoreReadPageResult,
53
+ type PortFailure,
54
+ type PortRequest,
55
+ type PortRequestEnvelope,
56
+ type PortResponse,
57
+ type PortResult,
58
+ } from "./port-protocol.ts";
59
+
60
+ type ConversationId = ConversationMaterialization["conversationId"];
61
+ type SubmissionId = SubmissionSnapshot["submissionId"];
62
+
63
+ const ConversationIdSchema = ConversationMaterialization.fields.conversationId;
64
+ const decodeConversationId = Schema.decodeUnknownEffect(ConversationIdSchema);
65
+
66
+ /**
67
+ * The ledger row bound routable Submission identities must respect (mirrors the local
68
+ * facet's `MAX_IDENTIFIER_LENGTH`; the minting side already refuses longer identities typed
69
+ * at admission, so a longer identity presented here cannot name any stored row).
70
+ */
71
+ const MAX_ROUTABLE_SUBMISSION_ID_LENGTH = 1_024;
72
+
73
+ /** The `{uuidv7}` head of a DC-minted routable Submission identity. */
74
+ const UUID_HEAD_PATTERN =
75
+ /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
76
+
77
+ /**
78
+ * A transport could not deliver a port request to (or an answer from) the owning
79
+ * Conversation's Durable Object. `retryable` carries the platform's own stub signal when one
80
+ * exists. This error never crosses the wire — it is the CALLER-side evidence that the
81
+ * authority was unreachable, which is exactly the case `AdmissionIndeterminate` was
82
+ * specified for (SUB-031).
83
+ */
84
+ export class PortTransportError extends Schema.TaggedError<PortTransportError>()(
85
+ "PortTransportError",
86
+ {
87
+ target: Schema.String,
88
+ message: Schema.String,
89
+ retryable: Schema.optionalKey(Schema.Boolean),
90
+ cause: Schema.optionalKey(Schema.Defect()),
91
+ },
92
+ ) {}
93
+
94
+ /**
95
+ * Build a `PortTransportError` from an arbitrary thrown transport cause, preserving the
96
+ * platform stub's own `retryable` signal when present.
97
+ */
98
+ export const portTransportFailure = (target: string, cause: unknown): PortTransportError => {
99
+ const message = cause instanceof Error ? cause.message : String(cause);
100
+ let retryable: boolean | undefined;
101
+ if (typeof cause === "object" && cause !== null && "retryable" in cause) {
102
+ const signal = cause.retryable;
103
+ if (typeof signal === "boolean") retryable = signal;
104
+ }
105
+ return PortTransportError.make({
106
+ target,
107
+ message: boundPortDiagnostic(message),
108
+ ...(retryable === undefined ? {} : { retryable }),
109
+ cause,
110
+ });
111
+ };
112
+
113
+ /**
114
+ * Delivery of Schema-encoded port envelopes to the Durable Object that owns a FOREIGN
115
+ * Conversation (plan §1.3, D-P6-3). The shipped implementation (platform-cloudflare, WP3)
116
+ * calls the owner's `portCall` over native Durable Object JS RPC via
117
+ * `namespace.idFromName(conversationId)`; the protocol is transport-agnostic and any carrier
118
+ * that moves the encoded envelopes verbatim satisfies this service. Implementations MUST
119
+ * surface every delivery problem as `PortTransportError` and must never fabricate an answer.
120
+ */
121
+ export class ConversationPortTransport extends Context.Service<
122
+ ConversationPortTransport,
123
+ {
124
+ readonly call: (
125
+ conversationId: ConversationId,
126
+ request: PortRequestEnvelope,
127
+ ) => Effect.Effect<unknown, PortTransportError>;
128
+ }
129
+ >()("@effect-agent/storage-cloudflare/ConversationPortTransport") {}
130
+
131
+ /** Construction options shared by both routed port Layers. */
132
+ export interface RoutedPortOptions {
133
+ /**
134
+ * The Conversation this Durable Object owns (the Object identity rule is
135
+ * `namespace.idFromName(conversationId)`). Requests addressed here execute on the local
136
+ * facet; requests addressed anywhere else route through the transport or fail fast typed.
137
+ */
138
+ readonly localConversationId: ConversationId;
139
+ }
140
+
141
+ /** Where one port request must execute. */
142
+ type RouteTarget =
143
+ | { readonly _tag: "local" }
144
+ | { readonly _tag: "foreign"; readonly conversationId: ConversationId };
145
+
146
+ const LOCAL: RouteTarget = { _tag: "local" };
147
+
148
+ /**
149
+ * Parse a DC-minted routable Submission identity — `{uuidv7}:{conversationId}`, split at the
150
+ * FIRST `:` because the Conversation tail may itself contain colons (D-P6-5). This adapter
151
+ * minted the format at admission and is the ONLY component that parses it; identities that do
152
+ * not carry the minted shape (no separator, non-UUID head, empty tail) fall back to the local
153
+ * facet, which is the only authority this Object can consult without inventing an owner.
154
+ * Identities beyond the ledger's 1,024-character row bound fail typed: the minting side
155
+ * refused them at admission, so they cannot name any stored row anywhere.
156
+ */
157
+ const routableSubmissionTarget = (
158
+ localConversationId: ConversationId,
159
+ ): ((operation: string, submissionId: string) => Effect.Effect<RouteTarget, LedgerError>) =>
160
+ Effect.fn("DoPortRouting.routableSubmissionTarget")(function* (
161
+ operation: string,
162
+ submissionId: string,
163
+ ): Effect.fn.Return<RouteTarget, LedgerError> {
164
+ if (submissionId.length > MAX_ROUTABLE_SUBMISSION_ID_LENGTH) {
165
+ return yield* LedgerError.make({
166
+ operation,
167
+ message:
168
+ `A Submission identity of ${submissionId.length} characters exceeds the ` +
169
+ `${MAX_ROUTABLE_SUBMISSION_ID_LENGTH}-character routable identity bound; admission ` +
170
+ "refuses such identities, so it cannot name any stored row.",
171
+ });
172
+ }
173
+ const separator = submissionId.indexOf(":");
174
+ if (separator === -1) return LOCAL;
175
+ if (!UUID_HEAD_PATTERN.test(submissionId.slice(0, separator))) return LOCAL;
176
+ const tail = submissionId.slice(separator + 1);
177
+ if (tail === localConversationId) return LOCAL;
178
+ return yield* decodeConversationId(tail).pipe(
179
+ Effect.map((conversationId): RouteTarget => ({ _tag: "foreign", conversationId })),
180
+ Effect.orElseSucceed(() => LOCAL),
181
+ );
182
+ });
183
+
184
+ const isResultTag =
185
+ <Tag extends PortResult["_tag"]>(tag: Tag) =>
186
+ (result: PortResult): result is Extract<PortResult, { readonly _tag: Tag }> =>
187
+ result._tag === tag;
188
+
189
+ const isAdmitConflict = (failure: PortFailure): failure is AdmissionConflict =>
190
+ failure._tag === "AdmissionConflict";
191
+ const isAbortConflict = (failure: PortFailure): failure is SettlementConflict | JoinedToHost =>
192
+ failure._tag === "SettlementConflict" || failure._tag === "JoinedToHost";
193
+ const noExtraFailure = (_failure: PortFailure): _failure is never => false;
194
+ const isFenceRejected = (failure: PortFailure): failure is FenceRejected =>
195
+ failure._tag === "FenceRejected";
196
+ const isAppendFailure = (
197
+ failure: PortFailure,
198
+ ): failure is ConversationNotMaterialized | AppendConflict | FenceRejected =>
199
+ failure._tag === "ConversationNotMaterialized" ||
200
+ failure._tag === "AppendConflict" ||
201
+ failure._tag === "FenceRejected";
202
+ const isNotMaterialized = (failure: PortFailure): failure is ConversationNotMaterialized =>
203
+ failure._tag === "ConversationNotMaterialized";
204
+
205
+ /**
206
+ * The fail-fast refusal for any foreign operation OUTSIDE the closed route-capable subset
207
+ * (plan §1.3): honesty over accidental distribution.
208
+ */
209
+ const crossConversationLedgerError = (operation: string, target: string): LedgerError =>
210
+ LedgerError.make({
211
+ operation,
212
+ message:
213
+ `${operation} addressed to foreign Conversation ${target} is not route-capable; the ` +
214
+ "closed cross-Object subset is admit, markReady, lookup, resolveAdmission, " +
215
+ "requestAbort, and recordChildSettled. Every other ledger operation is lane-local by " +
216
+ "construction and must execute inside the owning Conversation's Durable Object.",
217
+ });
218
+
219
+ const crossConversationStoreError = (operation: string, target: string): ConversationStoreError =>
220
+ ConversationStoreError.make({
221
+ operation,
222
+ message:
223
+ `${operation} addressed to foreign Conversation ${target} is not route-capable; the ` +
224
+ "closed cross-Object subset is materialize, append, read (paged), inspectTail, and " +
225
+ "export. Observation and checkpoints are lane-local by construction and must execute " +
226
+ "inside the owning Conversation's Durable Object.",
227
+ });
228
+
229
+ const makeTransportCall = (transport: ConversationPortTransport["Service"]) =>
230
+ Effect.fn("DoPortRouting.transportCall")(function* (target: ConversationId, call: PortRequest) {
231
+ const encoded = yield* encodePortRequest(call).pipe(
232
+ Effect.mapError((error) =>
233
+ PortProtocolError.make({
234
+ message: boundPortDiagnostic(`The port request could not be encoded: ${error.message}`),
235
+ }),
236
+ ),
237
+ );
238
+ const raw = yield* transport.call(target, encoded);
239
+ return yield* decodePortResponse(raw).pipe(
240
+ Effect.mapError((error) =>
241
+ PortProtocolError.make({
242
+ message: boundPortDiagnostic(`The port response could not be decoded: ${error.message}`),
243
+ }),
244
+ ),
245
+ );
246
+ });
247
+
248
+ type TransportCall = ReturnType<typeof makeTransportCall>;
249
+
250
+ const makeRoutedLedgerServices = Effect.fn("DoPortRouting.makeRoutedLedgerServices")(function* (
251
+ options: RoutedPortOptions,
252
+ ) {
253
+ const local = yield* SubmissionLedger;
254
+ const transport = yield* ConversationPortTransport;
255
+ const transportCall: TransportCall = makeTransportCall(transport);
256
+ const submissionTarget = routableSubmissionTarget(options.localConversationId);
257
+
258
+ const routeFailure =
259
+ (operation: string, target: string) =>
260
+ (error: PortTransportError | PortProtocolError): LedgerError =>
261
+ LedgerError.make({
262
+ operation,
263
+ message: boundPortDiagnostic(
264
+ `Routed ${operation} to the Conversation Object owning ${target} failed: ${error.message}`,
265
+ ),
266
+ cause: error,
267
+ });
268
+
269
+ /**
270
+ * One routed ledger call: encode, deliver, decode, then narrow the uniform envelope to the
271
+ * operation's own result and failure surface. A foreign `LedgerError` is always in-channel;
272
+ * any failure outside the operation's declared surface — including protocol anomalies — is
273
+ * folded into a `LedgerError` naming the anomaly instead of being erased or re-thrown raw.
274
+ */
275
+ const foreignLedgerCall = <Tag extends PortResult["_tag"], ExpectedFailure extends PortFailure>(
276
+ operation: string,
277
+ target: ConversationId,
278
+ call: PortRequest,
279
+ resultTag: Tag,
280
+ isExpectedFailure: (failure: PortFailure) => failure is ExpectedFailure,
281
+ ): Effect.Effect<Extract<PortResult, { readonly _tag: Tag }>, ExpectedFailure | LedgerError> =>
282
+ transportCall(target, call).pipe(
283
+ Effect.mapError(routeFailure(operation, target)),
284
+ Effect.flatMap(
285
+ (
286
+ response,
287
+ ): Effect.Effect<
288
+ Extract<PortResult, { readonly _tag: Tag }>,
289
+ ExpectedFailure | LedgerError
290
+ > => {
291
+ if (response._tag === "PortFailed") {
292
+ const failure = response.failure;
293
+ if (isExpectedFailure(failure)) return Effect.fail(failure);
294
+ if (failure._tag === "LedgerError") return Effect.fail(failure);
295
+ return Effect.fail(
296
+ LedgerError.make({
297
+ operation,
298
+ message: boundPortDiagnostic(
299
+ `The Conversation Object owning ${target} answered ${operation} with the ` +
300
+ `out-of-contract failure ${failure._tag}: ${failure.message}`,
301
+ ),
302
+ cause: failure,
303
+ }),
304
+ );
305
+ }
306
+ const result = response.result;
307
+ if (!isResultTag(resultTag)(result)) {
308
+ return Effect.fail(
309
+ LedgerError.make({
310
+ operation,
311
+ message:
312
+ `The Conversation Object owning ${target} answered ${operation} with the ` +
313
+ `mismatched result ${result._tag}; expected ${resultTag}.`,
314
+ }),
315
+ );
316
+ }
317
+ return Effect.succeed(result);
318
+ },
319
+ ),
320
+ Effect.withSpan("DoPortRouting.foreignLedgerCall", {
321
+ attributes: { operation, target },
322
+ }),
323
+ );
324
+
325
+ /**
326
+ * Routed `resolveAdmission` — where the S2 tri-state becomes real (plan §1.3): when the
327
+ * owning Object cannot be reached, or its answer cannot be understood, the routed adapter
328
+ * answers `AdmissionIndeterminate{reason}` and NEVER `NotAdmitted` — an unreachable
329
+ * authority proves nothing, and only `NotAdmitted` permits an admission attempt (SUB-031).
330
+ * A typed `LedgerError` answered BY the authority still fails typed: the authority was
331
+ * reached and reported its own storage failure.
332
+ */
333
+ const resolveForeignAdmission = (
334
+ target: ConversationId,
335
+ request: SubmissionLookupByKey,
336
+ ): Effect.Effect<
337
+ | AdmissionIndeterminate
338
+ | Extract<PortResult, { readonly _tag: "LedgerResolveAdmissionResult" }>["resolution"],
339
+ LedgerError
340
+ > =>
341
+ transportCall(target, LedgerResolveAdmissionCall.make({ request })).pipe(
342
+ Effect.flatMap((response) => {
343
+ if (response._tag === "PortFailed") {
344
+ if (response.failure._tag === "LedgerError") return Effect.fail(response.failure);
345
+ return Effect.succeed(
346
+ AdmissionIndeterminate.make({
347
+ reason: boundPortDiagnostic(
348
+ `The Conversation Object owning ${target} answered resolveAdmission with the ` +
349
+ `out-of-contract failure ${response.failure._tag}: ${response.failure.message}`,
350
+ ),
351
+ }),
352
+ );
353
+ }
354
+ if (response.result._tag !== "LedgerResolveAdmissionResult") {
355
+ return Effect.succeed(
356
+ AdmissionIndeterminate.make({
357
+ reason: boundPortDiagnostic(
358
+ `The Conversation Object owning ${target} answered resolveAdmission with the ` +
359
+ `mismatched result ${response.result._tag}.`,
360
+ ),
361
+ }),
362
+ );
363
+ }
364
+ return Effect.succeed(response.result.resolution);
365
+ }),
366
+ Effect.catchTags({
367
+ PortTransportError: (error) =>
368
+ Effect.succeed(
369
+ AdmissionIndeterminate.make({
370
+ reason: boundPortDiagnostic(
371
+ `The Conversation Object owning ${target} is unreachable: ${error.message}`,
372
+ ),
373
+ }),
374
+ ),
375
+ PortProtocolError: (error) =>
376
+ Effect.succeed(
377
+ AdmissionIndeterminate.make({
378
+ reason: boundPortDiagnostic(
379
+ `The answer of the Conversation Object owning ${target} could not be ` +
380
+ `understood: ${error.message}`,
381
+ ),
382
+ }),
383
+ ),
384
+ }),
385
+ Effect.withSpan("DoPortRouting.resolveForeignAdmission", { attributes: { target } }),
386
+ );
387
+
388
+ const foreignLookupById = (
389
+ operation: string,
390
+ target: ConversationId,
391
+ submissionId: SubmissionId,
392
+ ): Effect.Effect<Option.Option<SubmissionSnapshot>, LedgerError> =>
393
+ foreignLedgerCall(
394
+ operation,
395
+ target,
396
+ LedgerLookupCall.make({ request: SubmissionLookupById.make({ submissionId }) }),
397
+ "LedgerLookupResult",
398
+ noExtraFailure,
399
+ ).pipe(
400
+ Effect.map((result) =>
401
+ result.submission === undefined ? Option.none() : Option.some(result.submission),
402
+ ),
403
+ );
404
+
405
+ /**
406
+ * Enrich a LOCAL parent's recovery snapshot with the lane state of attached children whose
407
+ * rows live in other Durable Objects (plan §1.3): markers first (the local facet already
408
+ * consulted them), then a routed per-child `lookup` for any attached child that is neither
409
+ * local nor marker-settled. A transport failure surfaces as `LedgerError` so the alarm
410
+ * pass retries; the child's canonical Settlement remains the only authority (DUR-015).
411
+ */
412
+ const enrichChildAttachments = Effect.fn("DoPortRouting.enrichChildAttachments")(function* (
413
+ snapshot: RecoverySnapshot,
414
+ ): Effect.fn.Return<RecoverySnapshot, LedgerError> {
415
+ const operation = "ledger load recovery snapshot";
416
+ const attachments = new Map(
417
+ snapshot.childAttachments.map((attachment) => [attachment.childSubmissionId, attachment]),
418
+ );
419
+ let enriched = false;
420
+ for (const reservation of snapshot.childReservations) {
421
+ const childSubmissionId = reservation.childSubmissionId;
422
+ if (childSubmissionId === undefined || attachments.has(childSubmissionId)) continue;
423
+ const target = yield* submissionTarget(operation, childSubmissionId);
424
+ // A local or opaque child identity was already answered authoritatively by the local
425
+ // facet; absence there means the child admission never committed.
426
+ if (target._tag !== "foreign") continue;
427
+ const child = yield* foreignLookupById(operation, target.conversationId, childSubmissionId);
428
+ if (Option.isNone(child)) continue;
429
+ attachments.set(
430
+ childSubmissionId,
431
+ ChildAttachmentSnapshot.make({
432
+ toolCallId: reservation.parentToolCallId,
433
+ childSubmissionId,
434
+ childState: child.value.state,
435
+ ...(child.value.settledOutcome === undefined
436
+ ? {}
437
+ : { childOutcome: child.value.settledOutcome }),
438
+ }),
439
+ );
440
+ enriched = true;
441
+ }
442
+ if (!enriched) return snapshot;
443
+ // Rebuild in reservation (parent Tool Call) order, the order the local facet documents.
444
+ const ordered: Array<ChildAttachmentSnapshot> = [];
445
+ for (const reservation of snapshot.childReservations) {
446
+ if (reservation.childSubmissionId === undefined) continue;
447
+ const attachment = attachments.get(reservation.childSubmissionId);
448
+ if (attachment !== undefined) ordered.push(attachment);
449
+ }
450
+ return RecoverySnapshot.make({
451
+ submission: snapshot.submission,
452
+ joins: snapshot.joins,
453
+ approvalDecisions: snapshot.approvalDecisions,
454
+ unknownResolutions: snapshot.unknownResolutions,
455
+ childReservations: snapshot.childReservations,
456
+ childAttachments: ordered,
457
+ ...(snapshot.ownership === undefined ? {} : { ownership: snapshot.ownership }),
458
+ ...(snapshot.inputApplied === undefined ? {} : { inputApplied: snapshot.inputApplied }),
459
+ ...(snapshot.reservation === undefined ? {} : { reservation: snapshot.reservation }),
460
+ ...(snapshot.abortIntent === undefined ? {} : { abortIntent: snapshot.abortIntent }),
461
+ ...(snapshot.hostSubmissionId === undefined
462
+ ? {}
463
+ : { hostSubmissionId: snapshot.hostSubmissionId }),
464
+ ...(snapshot.suspension === undefined ? {} : { suspension: snapshot.suspension }),
465
+ ...(snapshot.parentLinkage === undefined ? {} : { parentLinkage: snapshot.parentLinkage }),
466
+ });
467
+ });
468
+
469
+ const routed = SubmissionLedger.of({
470
+ capabilities: local.capabilities,
471
+
472
+ admit: (request) =>
473
+ request.conversationId === options.localConversationId
474
+ ? local.admit(request)
475
+ : foreignLedgerCall(
476
+ "ledger admit",
477
+ request.conversationId,
478
+ LedgerAdmitCall.make({ request }),
479
+ "LedgerAdmitResult",
480
+ isAdmitConflict,
481
+ ).pipe(Effect.map((reply) => reply.result)),
482
+
483
+ markReady: (request) =>
484
+ submissionTarget("ledger mark ready", request.submissionId).pipe(
485
+ Effect.flatMap((target) =>
486
+ target._tag === "local"
487
+ ? local.markReady(request)
488
+ : foreignLedgerCall(
489
+ "ledger mark ready",
490
+ target.conversationId,
491
+ LedgerMarkReadyCall.make({ request }),
492
+ "LedgerMarkReadyResult",
493
+ noExtraFailure,
494
+ ).pipe(Effect.asVoid),
495
+ ),
496
+ ),
497
+
498
+ lookup: (request) =>
499
+ request._tag === "SubmissionLookupById"
500
+ ? submissionTarget("ledger lookup", request.submissionId).pipe(
501
+ Effect.flatMap((target) =>
502
+ target._tag === "local"
503
+ ? local.lookup(request)
504
+ : foreignLookupById("ledger lookup", target.conversationId, request.submissionId),
505
+ ),
506
+ )
507
+ : request.conversationId === options.localConversationId
508
+ ? local.lookup(request)
509
+ : foreignLedgerCall(
510
+ "ledger lookup",
511
+ request.conversationId,
512
+ LedgerLookupCall.make({ request }),
513
+ "LedgerLookupResult",
514
+ noExtraFailure,
515
+ ).pipe(
516
+ Effect.map((result) =>
517
+ result.submission === undefined ? Option.none() : Option.some(result.submission),
518
+ ),
519
+ ),
520
+
521
+ resolveAdmission: (request) =>
522
+ request.conversationId === options.localConversationId
523
+ ? local.resolveAdmission(request)
524
+ : resolveForeignAdmission(request.conversationId, request),
525
+
526
+ requestAbort: (request) =>
527
+ submissionTarget("ledger request abort", request.submissionId).pipe(
528
+ Effect.flatMap((target) =>
529
+ target._tag === "local"
530
+ ? local.requestAbort(request)
531
+ : foreignLedgerCall(
532
+ "ledger request abort",
533
+ target.conversationId,
534
+ LedgerRequestAbortCall.make({ request }),
535
+ "LedgerRequestAbortResult",
536
+ isAbortConflict,
537
+ ).pipe(Effect.map((reply) => reply.intent)),
538
+ ),
539
+ ),
540
+
541
+ recordChildSettled: (request) =>
542
+ submissionTarget("ledger record child settled", request.parentSubmissionId).pipe(
543
+ Effect.flatMap((target) =>
544
+ target._tag === "local"
545
+ ? local.recordChildSettled(request)
546
+ : foreignLedgerCall(
547
+ "ledger record child settled",
548
+ target.conversationId,
549
+ LedgerRecordChildSettledCall.make({ request }),
550
+ "LedgerRecordChildSettledResult",
551
+ noExtraFailure,
552
+ ).pipe(Effect.map((reply) => reply.outcome)),
553
+ ),
554
+ ),
555
+
556
+ // Every operation below is lane-local by construction (plan §1.3): a foreign address is
557
+ // an out-of-contract call and fails fast typed instead of being quietly distributed.
558
+ claim: (request) =>
559
+ request.conversationId === options.localConversationId
560
+ ? local.claim(request)
561
+ : Effect.fail(crossConversationLedgerError("ledger claim", request.conversationId)),
562
+
563
+ claimJoining: (request) =>
564
+ request.conversationId === options.localConversationId
565
+ ? local.claimJoining(request)
566
+ : Effect.fail(crossConversationLedgerError("ledger claim joining", request.conversationId)),
567
+
568
+ renewOwnership: (request) =>
569
+ submissionTarget("ledger renew ownership", request.submissionId).pipe(
570
+ Effect.flatMap((target) =>
571
+ target._tag === "local"
572
+ ? local.renewOwnership(request)
573
+ : Effect.fail(
574
+ crossConversationLedgerError("ledger renew ownership", target.conversationId),
575
+ ),
576
+ ),
577
+ ),
578
+
579
+ releaseOwnership: (request) =>
580
+ submissionTarget("ledger release ownership", request.submissionId).pipe(
581
+ Effect.flatMap((target) =>
582
+ target._tag === "local"
583
+ ? local.releaseOwnership(request)
584
+ : Effect.fail(
585
+ crossConversationLedgerError("ledger release ownership", target.conversationId),
586
+ ),
587
+ ),
588
+ ),
589
+
590
+ markInputApplied: (request) =>
591
+ submissionTarget("ledger mark input applied", request.submissionId).pipe(
592
+ Effect.flatMap((target) =>
593
+ target._tag === "local"
594
+ ? local.markInputApplied(request)
595
+ : Effect.fail(
596
+ crossConversationLedgerError("ledger mark input applied", target.conversationId),
597
+ ),
598
+ ),
599
+ ),
600
+
601
+ reserveSettlement: (request) =>
602
+ submissionTarget("ledger reserve settlement", request.submissionId).pipe(
603
+ Effect.flatMap((target) =>
604
+ target._tag === "local"
605
+ ? local.reserveSettlement(request)
606
+ : Effect.fail(
607
+ crossConversationLedgerError("ledger reserve settlement", target.conversationId),
608
+ ),
609
+ ),
610
+ ),
611
+
612
+ finalizeSettlement: (request) =>
613
+ submissionTarget("ledger finalize settlement", request.submissionId).pipe(
614
+ Effect.flatMap((target) =>
615
+ target._tag === "local"
616
+ ? local.finalizeSettlement(request)
617
+ : Effect.fail(
618
+ crossConversationLedgerError("ledger finalize settlement", target.conversationId),
619
+ ),
620
+ ),
621
+ ),
622
+
623
+ markJoined: (request) =>
624
+ submissionTarget("ledger mark joined", request.submissionId).pipe(
625
+ Effect.flatMap((target) =>
626
+ target._tag === "local"
627
+ ? local.markJoined(request)
628
+ : Effect.fail(
629
+ crossConversationLedgerError("ledger mark joined", target.conversationId),
630
+ ),
631
+ ),
632
+ ),
633
+
634
+ revertJoining: (request) =>
635
+ submissionTarget("ledger revert joining", request.submissionId).pipe(
636
+ Effect.flatMap((target) =>
637
+ target._tag === "local"
638
+ ? local.revertJoining(request)
639
+ : Effect.fail(
640
+ crossConversationLedgerError("ledger revert joining", target.conversationId),
641
+ ),
642
+ ),
643
+ ),
644
+
645
+ suspend: (request) =>
646
+ submissionTarget("ledger suspend", request.submissionId).pipe(
647
+ Effect.flatMap((target) =>
648
+ target._tag === "local"
649
+ ? local.suspend(request)
650
+ : Effect.fail(crossConversationLedgerError("ledger suspend", target.conversationId)),
651
+ ),
652
+ ),
653
+
654
+ recordApprovalDecision: (command) =>
655
+ submissionTarget("ledger record approval decision", command.submissionId).pipe(
656
+ Effect.flatMap((target) =>
657
+ target._tag === "local"
658
+ ? local.recordApprovalDecision(command)
659
+ : Effect.fail(
660
+ crossConversationLedgerError(
661
+ "ledger record approval decision",
662
+ target.conversationId,
663
+ ),
664
+ ),
665
+ ),
666
+ ),
667
+
668
+ markUnknown: (request) =>
669
+ submissionTarget("ledger mark unknown", request.submissionId).pipe(
670
+ Effect.flatMap((target) =>
671
+ target._tag === "local"
672
+ ? local.markUnknown(request)
673
+ : Effect.fail(
674
+ crossConversationLedgerError("ledger mark unknown", target.conversationId),
675
+ ),
676
+ ),
677
+ ),
678
+
679
+ recordUnknownResolution: (command) =>
680
+ submissionTarget("ledger record unknown resolution", command.submissionId).pipe(
681
+ Effect.flatMap((target) =>
682
+ target._tag === "local"
683
+ ? local.recordUnknownResolution(command)
684
+ : Effect.fail(
685
+ crossConversationLedgerError(
686
+ "ledger record unknown resolution",
687
+ target.conversationId,
688
+ ),
689
+ ),
690
+ ),
691
+ ),
692
+
693
+ reserveChildBudget: (request) =>
694
+ submissionTarget("ledger reserve child budget", request.parentSubmissionId).pipe(
695
+ Effect.flatMap((target) =>
696
+ target._tag === "local"
697
+ ? local.reserveChildBudget(request)
698
+ : Effect.fail(
699
+ crossConversationLedgerError("ledger reserve child budget", target.conversationId),
700
+ ),
701
+ ),
702
+ ),
703
+
704
+ // Reservation identities carry no Conversation address; the reservation row lives in the
705
+ // parent's own Object and these transitions are parent-lane-local by construction, so
706
+ // they always execute on the local facet (which fails typed for an unknown row).
707
+ attachChildToReservation: local.attachChildToReservation,
708
+ beginChildBudgetRelease: local.beginChildBudgetRelease,
709
+ releaseChildBudget: local.releaseChildBudget,
710
+
711
+ // The local scan IS the whole worklist: one Conversation per Object (durability §5).
712
+ scanNonterminal: local.scanNonterminal,
713
+
714
+ loadRecoverySnapshot: (request) =>
715
+ submissionTarget("ledger load recovery snapshot", request.submissionId).pipe(
716
+ Effect.flatMap((target) =>
717
+ target._tag === "local"
718
+ ? local.loadRecoverySnapshot(request).pipe(Effect.flatMap(enrichChildAttachments))
719
+ : Effect.fail(
720
+ crossConversationLedgerError(
721
+ "ledger load recovery snapshot",
722
+ target.conversationId,
723
+ ),
724
+ ),
725
+ ),
726
+ ),
727
+ });
728
+
729
+ return Context.make(SubmissionLedger, routed);
730
+ });
731
+
732
+ const makeRoutedStoreServices = Effect.fn("DoPortRouting.makeRoutedStoreServices")(function* (
733
+ options: RoutedPortOptions,
734
+ ) {
735
+ const local = yield* ConversationStore;
736
+ const transport = yield* ConversationPortTransport;
737
+ const transportCall: TransportCall = makeTransportCall(transport);
738
+
739
+ const routeFailure =
740
+ (operation: string, target: string) =>
741
+ (error: PortTransportError | PortProtocolError): ConversationStoreError =>
742
+ ConversationStoreError.make({
743
+ operation,
744
+ message: boundPortDiagnostic(
745
+ `Routed ${operation} to the Conversation Object owning ${target} failed: ${error.message}`,
746
+ ),
747
+ cause: error,
748
+ });
749
+
750
+ /** The store twin of `foreignLedgerCall` with `ConversationStoreError` as the base error. */
751
+ const foreignStoreCall = <Tag extends PortResult["_tag"], ExpectedFailure extends PortFailure>(
752
+ operation: string,
753
+ target: ConversationId,
754
+ call: PortRequest,
755
+ resultTag: Tag,
756
+ isExpectedFailure: (failure: PortFailure) => failure is ExpectedFailure,
757
+ ): Effect.Effect<
758
+ Extract<PortResult, { readonly _tag: Tag }>,
759
+ ExpectedFailure | ConversationStoreError
760
+ > =>
761
+ transportCall(target, call).pipe(
762
+ Effect.mapError(routeFailure(operation, target)),
763
+ Effect.flatMap(
764
+ (
765
+ response,
766
+ ): Effect.Effect<
767
+ Extract<PortResult, { readonly _tag: Tag }>,
768
+ ExpectedFailure | ConversationStoreError
769
+ > => {
770
+ if (response._tag === "PortFailed") {
771
+ const failure = response.failure;
772
+ if (isExpectedFailure(failure)) return Effect.fail(failure);
773
+ if (failure._tag === "ConversationStoreError") return Effect.fail(failure);
774
+ return Effect.fail(
775
+ ConversationStoreError.make({
776
+ operation,
777
+ message: boundPortDiagnostic(
778
+ `The Conversation Object owning ${target} answered ${operation} with the ` +
779
+ `out-of-contract failure ${failure._tag}: ${failure.message}`,
780
+ ),
781
+ cause: failure,
782
+ }),
783
+ );
784
+ }
785
+ const result = response.result;
786
+ if (!isResultTag(resultTag)(result)) {
787
+ return Effect.fail(
788
+ ConversationStoreError.make({
789
+ operation,
790
+ message:
791
+ `The Conversation Object owning ${target} answered ${operation} with the ` +
792
+ `mismatched result ${result._tag}; expected ${resultTag}.`,
793
+ }),
794
+ );
795
+ }
796
+ return Effect.succeed(result);
797
+ },
798
+ ),
799
+ Effect.withSpan("DoPortRouting.foreignStoreCall", {
800
+ attributes: { operation, target },
801
+ }),
802
+ );
803
+
804
+ const routed = ConversationStore.of({
805
+ materialize: (request) =>
806
+ request.conversationId === options.localConversationId
807
+ ? local.materialize(request)
808
+ : foreignStoreCall(
809
+ "conversation materialize",
810
+ request.conversationId,
811
+ StoreMaterializeCall.make({ request }),
812
+ "StoreMaterializeResult",
813
+ isFenceRejected,
814
+ ).pipe(Effect.asVoid),
815
+
816
+ append: (request) =>
817
+ request.conversationId === options.localConversationId
818
+ ? local.append(request)
819
+ : foreignStoreCall(
820
+ "conversation append",
821
+ request.conversationId,
822
+ StoreAppendCall.make({ request }),
823
+ "StoreAppendResult",
824
+ isAppendFailure,
825
+ ).pipe(Effect.map((reply) => reply.result)),
826
+
827
+ read: (request) =>
828
+ request.conversationId === options.localConversationId
829
+ ? local.read(request)
830
+ : Stream.unwrap(
831
+ foreignStoreCall(
832
+ "conversation read",
833
+ request.conversationId,
834
+ StoreReadPageCall.make({ request }),
835
+ "StoreReadPageResult",
836
+ isNotMaterialized,
837
+ ).pipe(Effect.map((reply) => Stream.fromIterable(reply.records))),
838
+ ),
839
+
840
+ inspectTail: (request) =>
841
+ request.conversationId === options.localConversationId
842
+ ? local.inspectTail(request)
843
+ : foreignStoreCall(
844
+ "conversation inspect tail",
845
+ request.conversationId,
846
+ StoreInspectTailCall.make({ request }),
847
+ "StoreInspectTailResult",
848
+ isNotMaterialized,
849
+ ).pipe(Effect.map((reply) => reply.tail)),
850
+
851
+ export: (request) =>
852
+ request.conversationId === options.localConversationId
853
+ ? local.export(request)
854
+ : foreignStoreCall(
855
+ "conversation export",
856
+ request.conversationId,
857
+ StoreExportCall.make({ request }),
858
+ "StoreExportResult",
859
+ isNotMaterialized,
860
+ ).pipe(Effect.map((reply) => reply.export)),
861
+
862
+ // Observation and checkpoints are lane-local by construction (plan §1.3): the closed
863
+ // route-capable store subset is materialize/append/read/inspectTail/export, and a
864
+ // foreign address on anything else fails fast typed.
865
+ observe: (request) =>
866
+ request.conversationId === options.localConversationId
867
+ ? local.observe(request)
868
+ : Stream.unwrap(
869
+ Effect.fail(
870
+ crossConversationStoreError("conversation observe", request.conversationId),
871
+ ),
872
+ ),
873
+
874
+ saveCheckpoint: (request) =>
875
+ request.checkpoint.conversationId === options.localConversationId
876
+ ? local.saveCheckpoint(request)
877
+ : Effect.fail(
878
+ crossConversationStoreError(
879
+ "conversation save checkpoint",
880
+ request.checkpoint.conversationId,
881
+ ),
882
+ ),
883
+
884
+ loadCheckpoint: (request) =>
885
+ request.conversationId === options.localConversationId
886
+ ? local.loadCheckpoint(request)
887
+ : Effect.fail(
888
+ crossConversationStoreError("conversation load checkpoint", request.conversationId),
889
+ ),
890
+ });
891
+
892
+ return Context.make(ConversationStore, routed);
893
+ });
894
+
895
+ /**
896
+ * Routing decorator over the LOCAL `SubmissionLedger` facet (plan §1.3): a request addressing
897
+ * this Object's Conversation executes locally; a route-capable request addressing another
898
+ * Conversation is Schema-encoded onto the `ConversationPortTransport` and executed by the
899
+ * owning Object's local facet; any other foreign request fails fast typed. Provide the WP1
900
+ * local facet (`submissionLedgerLayer`/`ledgerLayer`) and a transport to close it.
901
+ */
902
+ export const routedSubmissionLedgerLayer = (
903
+ options: RoutedPortOptions,
904
+ ): Layer.Layer<SubmissionLedger, never, SubmissionLedger | ConversationPortTransport> =>
905
+ Layer.effectContext(makeRoutedLedgerServices(options));
906
+
907
+ /**
908
+ * Routing decorator over the LOCAL `ConversationStore` facet (plan §1.3): this-conversation
909
+ * requests execute locally; foreign materialize/append/read/inspectTail/export travel the
910
+ * transport; foreign observation and checkpoints fail fast typed.
911
+ */
912
+ export const routedConversationStoreLayer = (
913
+ options: RoutedPortOptions,
914
+ ): Layer.Layer<ConversationStore, never, ConversationStore | ConversationPortTransport> =>
915
+ Layer.effectContext(makeRoutedStoreServices(options));
916
+
917
+ // ---------------------------------------------------------------------------
918
+ // Owner-side execution
919
+ // ---------------------------------------------------------------------------
920
+
921
+ /** Fold one port operation's typed failures into the uniform response envelope. */
922
+ const capture = <Failure extends PortFailure>(
923
+ effect: Effect.Effect<PortResult, Failure>,
924
+ ): Effect.Effect<PortResponse> =>
925
+ effect.pipe(
926
+ Effect.map((result): PortResponse => PortSucceeded.make({ result })),
927
+ Effect.catch((failure) => Effect.succeed<PortResponse>(PortFailed.make({ failure }))),
928
+ );
929
+
930
+ /**
931
+ * Execute one decoded port request against THIS Object's LOCAL facets — the owner-side half
932
+ * of the routed ports (plan §1.3). Callers must provide the WP1 local facets, never the
933
+ * routed decorators: the routing layer already established that this Object owns the
934
+ * addressed Conversation, and re-routing here could bounce a request between Objects.
935
+ * Failures never escape — every typed port failure becomes a `PortFailed` envelope that
936
+ * re-decodes on the caller side.
937
+ */
938
+ export const executePortRequest = Effect.fn("DoPortRouting.executePortRequest")(function* (
939
+ request: PortRequest,
940
+ ): Effect.fn.Return<PortResponse, never, SubmissionLedger | ConversationStore> {
941
+ switch (request._tag) {
942
+ case "LedgerAdmit": {
943
+ const ledger = yield* SubmissionLedger;
944
+ return yield* capture(
945
+ ledger
946
+ .admit(request.request)
947
+ .pipe(Effect.map((result) => LedgerAdmitResult.make({ result }))),
948
+ );
949
+ }
950
+ case "LedgerMarkReady": {
951
+ const ledger = yield* SubmissionLedger;
952
+ return yield* capture(
953
+ ledger.markReady(request.request).pipe(Effect.map(() => LedgerMarkReadyResult.make({}))),
954
+ );
955
+ }
956
+ case "LedgerLookup": {
957
+ const ledger = yield* SubmissionLedger;
958
+ return yield* capture(
959
+ ledger
960
+ .lookup(request.request)
961
+ .pipe(
962
+ Effect.map((submission) =>
963
+ Option.isSome(submission)
964
+ ? LedgerLookupResult.make({ submission: submission.value })
965
+ : LedgerLookupResult.make({}),
966
+ ),
967
+ ),
968
+ );
969
+ }
970
+ case "LedgerResolveAdmission": {
971
+ const ledger = yield* SubmissionLedger;
972
+ return yield* capture(
973
+ ledger
974
+ .resolveAdmission(request.request)
975
+ .pipe(Effect.map((resolution) => LedgerResolveAdmissionResult.make({ resolution }))),
976
+ );
977
+ }
978
+ case "LedgerRequestAbort": {
979
+ const ledger = yield* SubmissionLedger;
980
+ return yield* capture(
981
+ ledger
982
+ .requestAbort(request.request)
983
+ .pipe(Effect.map((intent) => LedgerRequestAbortResult.make({ intent }))),
984
+ );
985
+ }
986
+ case "LedgerRecordChildSettled": {
987
+ const ledger = yield* SubmissionLedger;
988
+ return yield* capture(
989
+ ledger
990
+ .recordChildSettled(request.request)
991
+ .pipe(Effect.map((outcome) => LedgerRecordChildSettledResult.make({ outcome }))),
992
+ );
993
+ }
994
+ case "StoreMaterialize": {
995
+ const store = yield* ConversationStore;
996
+ return yield* capture(
997
+ store.materialize(request.request).pipe(Effect.map(() => StoreMaterializeResult.make({}))),
998
+ );
999
+ }
1000
+ case "StoreAppend": {
1001
+ const store = yield* ConversationStore;
1002
+ return yield* capture(
1003
+ store
1004
+ .append(request.request)
1005
+ .pipe(Effect.map((result) => StoreAppendResult.make({ result }))),
1006
+ );
1007
+ }
1008
+ case "StoreReadPage": {
1009
+ const store = yield* ConversationStore;
1010
+ return yield* capture(
1011
+ store.read(request.request).pipe(
1012
+ Stream.runCollect,
1013
+ Effect.map((records) => StoreReadPageResult.make({ records: [...records] })),
1014
+ ),
1015
+ );
1016
+ }
1017
+ case "StoreInspectTail": {
1018
+ const store = yield* ConversationStore;
1019
+ return yield* capture(
1020
+ store
1021
+ .inspectTail(request.request)
1022
+ .pipe(Effect.map((tail) => StoreInspectTailResult.make({ tail }))),
1023
+ );
1024
+ }
1025
+ case "StoreExport": {
1026
+ const store = yield* ConversationStore;
1027
+ return yield* capture(
1028
+ store
1029
+ .export(request.request)
1030
+ .pipe(
1031
+ Effect.map((conversationExport) =>
1032
+ StoreExportResult.make({ export: conversationExport }),
1033
+ ),
1034
+ ),
1035
+ );
1036
+ }
1037
+ }
1038
+ });
1039
+
1040
+ /**
1041
+ * The last-resort wire fallback when even encoding a response fails: the literal encoded
1042
+ * form of `PortFailed(PortProtocolError)` — tagged classes of bounded strings encode to
1043
+ * exactly this shape, so no Schema round trip is needed to produce it.
1044
+ */
1045
+ const encodedProtocolFailure = (message: string): unknown => ({
1046
+ _tag: "PortFailed",
1047
+ failure: { _tag: "PortProtocolError", message: boundPortDiagnostic(message) },
1048
+ });
1049
+
1050
+ /**
1051
+ * The complete owner-side endpoint body for `portCall` (D-P6-3): decode the wire request,
1052
+ * execute it against this Object's LOCAL facets, and answer with the encoded response
1053
+ * envelope. Total by construction — a request that cannot be decoded, or a response that
1054
+ * cannot be encoded, answers `PortFailed(PortProtocolError)` instead of throwing, so the
1055
+ * transport never has to interpret exceptions as protocol answers.
1056
+ */
1057
+ export const handleEncodedPortRequest = Effect.fn("DoPortRouting.handleEncodedPortRequest")(
1058
+ function* (
1059
+ encoded: unknown,
1060
+ ): Effect.fn.Return<unknown, never, SubmissionLedger | ConversationStore> {
1061
+ const response = yield* decodePortRequest(encoded).pipe(
1062
+ Effect.flatMap(executePortRequest),
1063
+ Effect.catch((error) =>
1064
+ Effect.succeed<PortResponse>(
1065
+ PortFailed.make({
1066
+ failure: PortProtocolError.make({
1067
+ message: boundPortDiagnostic(
1068
+ `The port request could not be decoded: ${error.message}`,
1069
+ ),
1070
+ }),
1071
+ }),
1072
+ ),
1073
+ ),
1074
+ );
1075
+ return yield* encodePortResponse(response).pipe(
1076
+ Effect.catch((error) =>
1077
+ Effect.succeed(
1078
+ encodedProtocolFailure(`The port response could not be encoded: ${error.message}`),
1079
+ ),
1080
+ ),
1081
+ );
1082
+ },
1083
+ );