deepline 0.3.114 → 0.3.115

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.
@@ -0,0 +1,942 @@
1
+ import type { Measured } from './scheduled-work-v2';
2
+ import type { QueueObservation } from './queue-health';
3
+
4
+ /**
5
+ * Scheduler-neutral dead-letter contract.
6
+ *
7
+ * A DLQ is durable work, not a log line. Adapters publish a point-in-time
8
+ * inventory, preserve measurement uncertainty, and expose only the actions
9
+ * their owning system can fence safely. The dashboard and admin CLI consume
10
+ * this contract; they never infer a delete/replay operation from a count.
11
+ */
12
+ export const DLQ_CONTRACT_VERSION = 1 as const;
13
+ /** Version for stable `<queue-id>.<verb>` path lookup keys. */
14
+ export const DLQ_PATH_INDEX_VERSION = 1 as const;
15
+
16
+ export type DlqMeasurement = Measured<number>;
17
+
18
+ export type DlqDisposition =
19
+ | 'open'
20
+ | 'uncertain'
21
+ | 'cleared'
22
+ | 'not_applicable'
23
+ | 'unavailable';
24
+
25
+ export type DlqAction = 'inspect' | 'redrive' | 'discard' | 'mark_done';
26
+
27
+ /**
28
+ * A source-owned path through a DLQ state machine. `pathId` is the stable
29
+ * operator lookup key; `lookupIndex` is the bounded source index (when one is
30
+ * registered), so an operator can find the exact records without scanning a
31
+ * whole table. Paths describe authority and state transitions; they do not
32
+ * grant permission to mutate.
33
+ */
34
+ export type DlqPathAction =
35
+ | DlqAction
36
+ | 'handoff'
37
+ | 'recover'
38
+ | 'reconcile'
39
+ | 'replay'
40
+ | 'retry'
41
+ | 'quarantine'
42
+ | 'release'
43
+ | 'settle';
44
+
45
+ export type DlqLookupIndex = {
46
+ readonly table: string;
47
+ readonly index: string;
48
+ readonly key: string;
49
+ readonly bounded: true;
50
+ };
51
+
52
+ export type DlqTransitionPath = {
53
+ /** Stable path index: `<queue-id>.<verb>`. */
54
+ readonly pathId: string;
55
+ readonly action: DlqPathAction;
56
+ readonly fromStates: readonly string[];
57
+ readonly toState: string;
58
+ readonly owner: string;
59
+ readonly source: string;
60
+ readonly lookupIndex?: DlqLookupIndex;
61
+ /** Existing owner command, when one is already exposed. */
62
+ readonly command?: string;
63
+ readonly enabled: boolean;
64
+ readonly requiresApproval: boolean;
65
+ readonly reason: string;
66
+ };
67
+
68
+ export type DlqActionCapability = {
69
+ readonly action: DlqAction;
70
+ readonly enabled: boolean;
71
+ readonly reason: string;
72
+ };
73
+
74
+ export type DlqInventory = {
75
+ readonly contractVersion: typeof DLQ_CONTRACT_VERSION;
76
+ readonly queueId: string;
77
+ readonly environment: string;
78
+ readonly source: string;
79
+ readonly observedAt: number;
80
+ readonly size: DlqMeasurement;
81
+ readonly oldestAgeMs: DlqMeasurement;
82
+ readonly disposition: DlqDisposition;
83
+ readonly capabilities: readonly DlqActionCapability[];
84
+ /** Optional on the low-level adapter for additive wire compatibility. */
85
+ readonly paths?: readonly DlqTransitionPath[];
86
+ };
87
+
88
+ export type DlqSummaryEntry = {
89
+ readonly id: string;
90
+ readonly name: string;
91
+ readonly owner: string;
92
+ readonly kind: string;
93
+ readonly source: string;
94
+ readonly adapter?: string;
95
+ readonly observedAt?: number;
96
+ readonly unresolved: DlqMeasurement;
97
+ readonly oldestAgeMs: DlqMeasurement;
98
+ readonly disposition: DlqDisposition;
99
+ readonly capabilities: readonly DlqActionCapability[];
100
+ readonly paths: readonly DlqTransitionPath[];
101
+ };
102
+
103
+ export type DlqSummary = {
104
+ /** Optional for readers that still receive the pre-path-index payload. */
105
+ readonly pathIndexVersion?: typeof DLQ_PATH_INDEX_VERSION;
106
+ readonly total: DlqMeasurement;
107
+ readonly queuesWithUnresolved: number;
108
+ readonly queuesNeedingConfirmation: number;
109
+ readonly observedQueues: number;
110
+ readonly unavailableQueues: readonly {
111
+ id: string;
112
+ name: string;
113
+ reason: string;
114
+ }[];
115
+ readonly entries: readonly DlqSummaryEntry[];
116
+ readonly dispositionPolicy: {
117
+ kind: 'owner_defined';
118
+ label: string;
119
+ description: string;
120
+ };
121
+ };
122
+
123
+ export type DlqAdapterContext = {
124
+ readonly environment: string;
125
+ readonly now: number;
126
+ readonly limit: number;
127
+ readonly cursor?: string;
128
+ };
129
+
130
+ export type DlqApprovalActorKind = 'queue_owner' | 'deepline_employee';
131
+
132
+ /**
133
+ * Short-lived, target-bound approval for a potentially mutating DLQ action.
134
+ * The actor identity must be authenticated by the admin/control-plane
135
+ * boundary; these fields are the durable hand-off between that boundary and
136
+ * the queue adapter, not a self-asserted permission check.
137
+ */
138
+ export type DlqApproval = {
139
+ readonly approvalId: string;
140
+ readonly actorId: string;
141
+ readonly actorKind: DlqApprovalActorKind;
142
+ readonly reason: string;
143
+ readonly approvedAt: number;
144
+ readonly expiresAt: number;
145
+ readonly queueId: string;
146
+ readonly environment: string;
147
+ readonly action: Exclude<DlqAction, 'inspect'>;
148
+ readonly fingerprint?: string;
149
+ };
150
+
151
+ export type DlqApplyContext = DlqAdapterContext & {
152
+ /** Required even for the read-only adapter so mutating implementations
153
+ * cannot accidentally grow an approval-free call path. */
154
+ readonly approval: DlqApproval;
155
+ };
156
+
157
+ export type DlqPlan = {
158
+ readonly contractVersion: typeof DLQ_CONTRACT_VERSION;
159
+ readonly queueId: string;
160
+ readonly environment: string;
161
+ readonly action: Exclude<DlqAction, 'inspect'>;
162
+ readonly itemIds: readonly string[];
163
+ readonly snapshot: DlqInventory;
164
+ readonly allowed: boolean;
165
+ readonly requiresApproval: true;
166
+ readonly reason: string;
167
+ readonly fingerprint?: string;
168
+ };
169
+
170
+ export type DlqApplyResult = {
171
+ readonly contractVersion: typeof DLQ_CONTRACT_VERSION;
172
+ readonly queueId: string;
173
+ readonly environment: string;
174
+ readonly action: Exclude<DlqAction, 'inspect'>;
175
+ readonly applied: readonly string[];
176
+ readonly skipped: readonly { id: string; reason: string }[];
177
+ readonly verificationRequired: boolean;
178
+ };
179
+
180
+ /** Every backend—including Absurd—must implement this port at its boundary. */
181
+ export type DlqAdapter = {
182
+ readonly id: string;
183
+ readonly queueId: string;
184
+ readonly source: string;
185
+ inspect(context: DlqAdapterContext): Promise<DlqInventory>;
186
+ plan(
187
+ context: DlqAdapterContext,
188
+ request: {
189
+ action: Exclude<DlqAction, 'inspect'>;
190
+ itemIds?: readonly string[];
191
+ },
192
+ ): Promise<DlqPlan>;
193
+ apply(context: DlqApplyContext, plan: DlqPlan): Promise<DlqApplyResult>;
194
+ };
195
+
196
+ export const DLQ_PLAN_MAX_SNAPSHOT_AGE_MS = 5 * 60 * 1_000;
197
+ export const DLQ_APPROVAL_MAX_TTL_MS = 15 * 60 * 1_000;
198
+
199
+ export type DlqApprovalExpectation = {
200
+ readonly queueId: string;
201
+ readonly environment: string;
202
+ readonly action: Exclude<DlqAction, 'inspect'>;
203
+ readonly fingerprint?: string;
204
+ readonly now: number;
205
+ };
206
+
207
+ /** Validate the durable approval hand-off before a queue adapter mutates. */
208
+ export function validateDlqApproval(
209
+ approval: DlqApproval,
210
+ expected: DlqApprovalExpectation,
211
+ ): void {
212
+ for (const [label, value] of [
213
+ ['approvalId', approval.approvalId],
214
+ ['actorId', approval.actorId],
215
+ ['reason', approval.reason],
216
+ ['queueId', approval.queueId],
217
+ ['environment', approval.environment],
218
+ ] as const) {
219
+ if (!value.trim()) throw new Error(`DLQ approval ${label} is required.`);
220
+ }
221
+ if (
222
+ approval.actorKind !== 'queue_owner' &&
223
+ approval.actorKind !== 'deepline_employee'
224
+ ) {
225
+ throw new Error(
226
+ 'DLQ approval actor must be a queue owner or Deepline employee.',
227
+ );
228
+ }
229
+ if (approval.queueId !== expected.queueId) {
230
+ throw new Error('DLQ approval queue does not match the plan.');
231
+ }
232
+ if (approval.environment !== expected.environment) {
233
+ throw new Error('DLQ approval environment does not match the plan.');
234
+ }
235
+ if (approval.action !== expected.action) {
236
+ throw new Error('DLQ approval action does not match the plan.');
237
+ }
238
+ if (
239
+ expected.fingerprint !== undefined &&
240
+ approval.fingerprint !== expected.fingerprint
241
+ ) {
242
+ throw new Error('DLQ approval fingerprint does not match the plan.');
243
+ }
244
+ if (
245
+ !Number.isFinite(approval.approvedAt) ||
246
+ !Number.isFinite(approval.expiresAt) ||
247
+ approval.expiresAt <= approval.approvedAt ||
248
+ approval.expiresAt - approval.approvedAt > DLQ_APPROVAL_MAX_TTL_MS
249
+ ) {
250
+ throw new Error('DLQ approval must have a valid short-lived expiry.');
251
+ }
252
+ if (
253
+ approval.approvedAt > expected.now ||
254
+ approval.expiresAt <= expected.now
255
+ ) {
256
+ throw new Error('DLQ approval is expired or not active yet.');
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Common fail-closed guard for every mutating adapter. Implementations should
262
+ * call this immediately before their source-owned compare-and-set mutation.
263
+ */
264
+ export function assertDlqApplyAuthorized(
265
+ context: DlqApplyContext,
266
+ plan: DlqPlan,
267
+ ): void {
268
+ if (plan.contractVersion !== DLQ_CONTRACT_VERSION) {
269
+ throw new Error('DLQ plan contract version is unsupported.');
270
+ }
271
+ if (!plan.allowed) throw new Error('DLQ plan is not allowed to mutate.');
272
+ if (!plan.requiresApproval) {
273
+ throw new Error('DLQ mutation plans must require explicit approval.');
274
+ }
275
+ if (plan.environment !== context.environment) {
276
+ throw new Error('DLQ apply environment does not match the plan.');
277
+ }
278
+ if (plan.queueId !== plan.snapshot.queueId) {
279
+ throw new Error('DLQ plan snapshot does not match the queue.');
280
+ }
281
+ if (plan.environment !== plan.snapshot.environment) {
282
+ throw new Error('DLQ plan snapshot does not match the environment.');
283
+ }
284
+ validateDlqInventory(plan.snapshot);
285
+ if (
286
+ plan.snapshot.disposition === 'unavailable' ||
287
+ plan.snapshot.disposition === 'uncertain'
288
+ ) {
289
+ throw new Error('DLQ plan snapshot is not trustworthy enough to mutate.');
290
+ }
291
+ if (
292
+ !Number.isFinite(context.now) ||
293
+ context.now < plan.snapshot.observedAt ||
294
+ context.now - plan.snapshot.observedAt > DLQ_PLAN_MAX_SNAPSHOT_AGE_MS
295
+ ) {
296
+ throw new Error(
297
+ 'DLQ plan snapshot is stale; inspect again before applying.',
298
+ );
299
+ }
300
+ validateDlqApproval(context.approval, {
301
+ queueId: plan.queueId,
302
+ environment: plan.environment,
303
+ action: plan.action,
304
+ fingerprint: plan.fingerprint,
305
+ now: context.now,
306
+ });
307
+ }
308
+
309
+ export function dispositionForMeasurement(
310
+ measurement: DlqMeasurement,
311
+ ): DlqDisposition {
312
+ if (measurement.kind === 'unavailable') return 'unavailable';
313
+ if (measurement.kind === 'not_applicable') return 'not_applicable';
314
+ if (measurement.kind === 'lower_bound') {
315
+ return measurement.value > 0 ? 'open' : 'uncertain';
316
+ }
317
+ return measurement.value > 0 ? 'open' : 'cleared';
318
+ }
319
+
320
+ type DlqPathMetadata = {
321
+ readonly lookupIndex?: DlqLookupIndex;
322
+ readonly transitions?: readonly {
323
+ readonly action: Exclude<DlqPathAction, 'inspect'>;
324
+ readonly fromStates: readonly string[];
325
+ readonly toState: string;
326
+ readonly enabled: boolean;
327
+ readonly requiresApproval: boolean;
328
+ readonly command?: string;
329
+ readonly reason: string;
330
+ }[];
331
+ };
332
+
333
+ const DLQ_PATH_METADATA: Readonly<Record<string, DlqPathMetadata>> = {
334
+ 'run-ledger-projector-outbox': {
335
+ lookupIndex: {
336
+ table: 'runtime_scheduler.outbox',
337
+ index: 'runtime_scheduler_outbox_dead_health_idx',
338
+ key: 'delivered_at IS NULL + failed_at',
339
+ bounded: true,
340
+ },
341
+ transitions: [
342
+ {
343
+ action: 'retry',
344
+ fromStates: ['failed'],
345
+ toState: 'pending',
346
+ enabled: false,
347
+ requiresApproval: true,
348
+ reason:
349
+ 'The runtime projector owner must verify the causal Run Ledger event and idempotency fence before retrying.',
350
+ },
351
+ ],
352
+ },
353
+ 'compute-billing-jobs': {
354
+ lookupIndex: {
355
+ table: 'runtime_scheduler.compute_billing_jobs_v2',
356
+ index: 'runtime_scheduler_compute_billing_blocked_health_idx',
357
+ key: 'status <> settled + blocked_at',
358
+ bounded: true,
359
+ },
360
+ transitions: [
361
+ {
362
+ action: 'reconcile',
363
+ fromStates: ['blocked'],
364
+ toState: 'pending',
365
+ enabled: false,
366
+ requiresApproval: true,
367
+ reason:
368
+ 'Billing must prove run, ledger, and provider evidence before unblocking a financial settlement obligation.',
369
+ },
370
+ ],
371
+ },
372
+ 'sandbox-cleanup-jobs': {
373
+ lookupIndex: {
374
+ table: 'runtime_scheduler.sandbox_cleanup_jobs_v2',
375
+ index: 'runtime_scheduler_sandbox_cleanup_v2_blocked_health_idx',
376
+ key: 'status <> deleted + blocked_at/status=deleting',
377
+ bounded: true,
378
+ },
379
+ transitions: [
380
+ {
381
+ action: 'retry',
382
+ fromStates: ['blocked', 'deleting'],
383
+ toState: 'pending',
384
+ enabled: false,
385
+ requiresApproval: true,
386
+ reason:
387
+ 'The runtime cleanup owner must verify provider resource identity and deletion idempotency before retrying.',
388
+ },
389
+ ],
390
+ },
391
+ 'play-webhook-receipts': {
392
+ lookupIndex: {
393
+ table: 'playWebhookReceipts',
394
+ index: 'by_state_created_at',
395
+ key: 'state + createdAt',
396
+ bounded: true,
397
+ },
398
+ transitions: [
399
+ {
400
+ action: 'handoff',
401
+ fromStates: ['blocked', 'expired'],
402
+ toState: 'pending',
403
+ enabled: true,
404
+ requiresApproval: true,
405
+ command:
406
+ 'deepline-admin runtime webhook-receipts handoff <receipt-id> --reason <reason> --environment production --dry-run',
407
+ reason:
408
+ 'The owner has a target-deployment handoff command; it fences exact receipts before publishing wakes.',
409
+ },
410
+ ],
411
+ },
412
+ 'workflow-cron-bindings': {
413
+ lookupIndex: {
414
+ table: 'workflowBindings',
415
+ index: 'by_type_status_nextScheduledAt',
416
+ key: 'type=cron + status=active + nextScheduledAt',
417
+ bounded: true,
418
+ },
419
+ },
420
+ 'play-cron-bindings': {
421
+ lookupIndex: {
422
+ table: 'playTriggerBindings',
423
+ index: 'by_type_status_nextScheduledAt',
424
+ key: 'type=cron + status=active + nextScheduledAt',
425
+ bounded: true,
426
+ },
427
+ },
428
+ 'ingestion-jobs': {
429
+ lookupIndex: {
430
+ table: 'tenantGraphAdoptionJobs',
431
+ index: 'by_status',
432
+ key: 'status',
433
+ bounded: true,
434
+ },
435
+ transitions: [
436
+ {
437
+ action: 'retry',
438
+ fromStates: ['failed'],
439
+ toState: 'queued',
440
+ enabled: false,
441
+ requiresApproval: true,
442
+ reason:
443
+ 'Retry is source-owned and not registered because these rows may be superseded legacy graph-adoption work.',
444
+ },
445
+ ],
446
+ },
447
+ 'monitor-fleet-convergence': {
448
+ lookupIndex: {
449
+ table: 'providerMonitorDefinitions',
450
+ index: 'by_convergence_due',
451
+ key: 'convergence + dueAt',
452
+ bounded: true,
453
+ },
454
+ transitions: [
455
+ {
456
+ action: 'retry',
457
+ fromStates: ['failed', 'blocked'],
458
+ toState: 'pending',
459
+ enabled: false,
460
+ requiresApproval: true,
461
+ reason:
462
+ 'Provider convergence retries require the monitor owner to verify provider truth and the active binding first.',
463
+ },
464
+ ],
465
+ },
466
+ 'monitor-deployment-jobs': {
467
+ lookupIndex: {
468
+ table: 'monitorDeploymentJobs',
469
+ index: 'by_state_updated',
470
+ key: 'state + updatedAt',
471
+ bounded: true,
472
+ },
473
+ transitions: [
474
+ {
475
+ action: 'retry',
476
+ fromStates: ['failed'],
477
+ toState: 'queued',
478
+ enabled: false,
479
+ requiresApproval: true,
480
+ reason:
481
+ 'Deployment retry is not a shared mutation; the monitor owner must confirm the desired definition and provider state.',
482
+ },
483
+ ],
484
+ },
485
+ 'provider-monitor-reconciliation': {
486
+ lookupIndex: {
487
+ table: 'providerMonitorGlobalAuditRuns',
488
+ index: 'by_provider_updated',
489
+ key: 'provider + updatedAt',
490
+ bounded: true,
491
+ },
492
+ transitions: [
493
+ {
494
+ action: 'reconcile',
495
+ fromStates: ['failed', 'blocked'],
496
+ toState: 'pending',
497
+ enabled: false,
498
+ requiresApproval: true,
499
+ reason:
500
+ 'Reconciliation retry needs a fresh provider inventory and a named audit run; age alone is not a replay signal.',
501
+ },
502
+ ],
503
+ },
504
+ 'signal-radar-inbound-dlq': {
505
+ lookupIndex: {
506
+ table: 'providerMonitorReconciliationSourceDeadLetters',
507
+ index: 'by_provider_last_seen',
508
+ key: 'provider + lastSeenAt',
509
+ bounded: true,
510
+ },
511
+ transitions: [
512
+ {
513
+ action: 'recover',
514
+ fromStates: ['dead_lettered', 'blocked'],
515
+ toState: 'pending',
516
+ enabled: false,
517
+ requiresApproval: true,
518
+ reason:
519
+ 'Recovery must prove upstream ownership, current monitor binding, and idempotent re-enqueue; no generic replay is allowed.',
520
+ },
521
+ ],
522
+ },
523
+ 'charge-reconciliation-backlog': {
524
+ lookupIndex: {
525
+ table: 'chargeReservations',
526
+ index: 'by_status_deadline',
527
+ key: 'status + deadline',
528
+ bounded: true,
529
+ },
530
+ transitions: [
531
+ {
532
+ action: 'quarantine',
533
+ fromStates: ['open'],
534
+ toState: 'parked',
535
+ enabled: true,
536
+ requiresApproval: true,
537
+ command:
538
+ 'deepline-admin billing-reservations park --older-than-days <days> --reason <reason> --environment production --dry-run',
539
+ reason:
540
+ 'Billing owns an explicit, reversible parking path for open reservations with no customer funds held.',
541
+ },
542
+ {
543
+ action: 'settle',
544
+ fromStates: ['parked'],
545
+ toState: 'open',
546
+ enabled: true,
547
+ requiresApproval: true,
548
+ command:
549
+ 'deepline-admin billing-reservations settle-parked --org-id <org-id> --reason <reason> --environment production --dry-run',
550
+ reason:
551
+ 'Only the billing owner can return parked reservations to the settlement worker after provider truth is verified.',
552
+ },
553
+ {
554
+ action: 'release',
555
+ fromStates: ['parked'],
556
+ toState: 'released',
557
+ enabled: true,
558
+ requiresApproval: true,
559
+ command:
560
+ 'deepline-admin billing-reservations release-parked --org-id <org-id> --reason <reason> --environment production --dry-run',
561
+ reason:
562
+ 'Release is an audited write-off path; it must not be inferred from age or an unresolved count.',
563
+ },
564
+ ],
565
+ },
566
+ 'metronome-usage-delivery': {
567
+ lookupIndex: {
568
+ table: 'billingUsageOutbox',
569
+ index: 'by_status_nextAttemptAt',
570
+ key: 'status + nextAttemptAt',
571
+ bounded: true,
572
+ },
573
+ transitions: [
574
+ {
575
+ action: 'retry',
576
+ fromStates: ['dead', 'needs_review'],
577
+ toState: 'pending',
578
+ enabled: false,
579
+ requiresApproval: true,
580
+ reason:
581
+ 'Retry requires a provider allocation check; the minute export cron is currently the source-owned recovery path.',
582
+ },
583
+ ],
584
+ },
585
+ 'billing-operations': {
586
+ lookupIndex: {
587
+ table: 'billingOperations',
588
+ index: 'by_state_nextAttemptAt',
589
+ key: 'state + nextAttemptAt',
590
+ bounded: true,
591
+ },
592
+ transitions: [
593
+ {
594
+ action: 'reconcile',
595
+ fromStates: ['needs_review', 'failed'],
596
+ toState: 'applied_or_aborted',
597
+ enabled: false,
598
+ requiresApproval: true,
599
+ reason:
600
+ 'Billing review rows require immutable provider allocation/payment evidence before an applied or aborted outcome is written.',
601
+ },
602
+ ],
603
+ },
604
+ 'stripe-financial-inbox': {
605
+ lookupIndex: {
606
+ table: 'stripeBillingEvents',
607
+ index: 'by_financialStatus_nextAttemptAt',
608
+ key: 'financialStatus + financialNextAttemptAt',
609
+ bounded: true,
610
+ },
611
+ transitions: [
612
+ {
613
+ action: 'reconcile',
614
+ fromStates: ['needs_review'],
615
+ toState: 'processed',
616
+ enabled: false,
617
+ requiresApproval: true,
618
+ reason:
619
+ 'Stripe review requires immutable event read-back and payment truth; never delete or blindly replay an event.',
620
+ },
621
+ ],
622
+ },
623
+ 'async-usage-recovery': {
624
+ lookupIndex: {
625
+ table: 'chargeReservations',
626
+ index: 'by_mode_status_deadline',
627
+ key: 'mode + status + deadline',
628
+ bounded: true,
629
+ },
630
+ transitions: [
631
+ {
632
+ action: 'reconcile',
633
+ fromStates: ['open', 'parked'],
634
+ toState: 'settled_or_released',
635
+ enabled: false,
636
+ requiresApproval: true,
637
+ reason:
638
+ 'Provider callback truth must be reconciled per reservation; bulk release is unsafe.',
639
+ },
640
+ ],
641
+ },
642
+ 'notification-deliveries': {
643
+ lookupIndex: {
644
+ table: 'productNotificationDeliveries',
645
+ index: 'by_state_next_attempt',
646
+ key: 'state + nextAttemptAt',
647
+ bounded: true,
648
+ },
649
+ transitions: [
650
+ {
651
+ action: 'retry',
652
+ fromStates: ['failed', 'dead_lettered'],
653
+ toState: 'pending',
654
+ enabled: false,
655
+ requiresApproval: true,
656
+ reason:
657
+ 'Notification retry is source-owned and must preserve recipient idempotency and delivery policy.',
658
+ },
659
+ ],
660
+ },
661
+ 'play-deployment-jobs': {
662
+ lookupIndex: {
663
+ table: 'playDeployments',
664
+ index: 'by_status_createdAt',
665
+ key: 'status + createdAt',
666
+ bounded: true,
667
+ },
668
+ transitions: [
669
+ {
670
+ action: 'retry',
671
+ fromStates: ['failed', 'reconcile_failed'],
672
+ toState: 'queued',
673
+ enabled: false,
674
+ requiresApproval: true,
675
+ reason:
676
+ 'Deployment repair must verify the artifact and active release before enqueueing a new build.',
677
+ },
678
+ ],
679
+ },
680
+ };
681
+
682
+ /** Return the stable, source-owned paths for one queue. */
683
+ export function getDlqTransitionPaths(input: {
684
+ queueId: string;
685
+ owner: string;
686
+ source: string;
687
+ }): readonly DlqTransitionPath[] {
688
+ const metadata = DLQ_PATH_METADATA[input.queueId] ?? {};
689
+ const inspect: DlqTransitionPath = {
690
+ pathId: `${input.queueId}.inspect`,
691
+ action: 'inspect',
692
+ fromStates: ['dead_lettered', 'blocked', 'uncertain'],
693
+ toState: 'unchanged',
694
+ owner: input.owner,
695
+ source: input.source,
696
+ ...(metadata.lookupIndex ? { lookupIndex: metadata.lookupIndex } : {}),
697
+ enabled: true,
698
+ requiresApproval: false,
699
+ reason:
700
+ 'Read the bounded inventory and exact item evidence from the owning source.',
701
+ };
702
+ const transitions = (metadata.transitions ?? []).map((path) => ({
703
+ ...path,
704
+ pathId: `${input.queueId}.${path.action}`,
705
+ owner: input.owner,
706
+ source: input.source,
707
+ ...(metadata.lookupIndex ? { lookupIndex: metadata.lookupIndex } : {}),
708
+ }));
709
+ return [inspect, ...transitions];
710
+ }
711
+
712
+ export function validateDlqTransitionPaths(
713
+ paths: readonly DlqTransitionPath[],
714
+ ): void {
715
+ const ids = new Set<string>();
716
+ for (const path of paths) {
717
+ if (!path.pathId.trim()) throw new Error('DLQ pathId is required.');
718
+ if (ids.has(path.pathId))
719
+ throw new Error(`Duplicate DLQ pathId: ${path.pathId}`);
720
+ ids.add(path.pathId);
721
+ if (!path.owner.trim()) throw new Error('DLQ path owner is required.');
722
+ if (!path.source.trim()) throw new Error('DLQ path source is required.');
723
+ if (path.fromStates.length === 0)
724
+ throw new Error(`DLQ path ${path.pathId} needs a source state.`);
725
+ if (!path.toState.trim())
726
+ throw new Error(`DLQ path ${path.pathId} needs a target state.`);
727
+ if (path.lookupIndex && !path.lookupIndex.bounded)
728
+ throw new Error(`DLQ path ${path.pathId} lookup must be bounded.`);
729
+ if (path.enabled && path.action !== 'inspect' && !path.requiresApproval)
730
+ throw new Error(
731
+ `DLQ mutation path ${path.pathId} must require approval.`,
732
+ );
733
+ }
734
+ }
735
+
736
+ export function summarizeDlqInventories(
737
+ inventories: readonly (DlqInventory & {
738
+ readonly name: string;
739
+ readonly owner: string;
740
+ readonly kind: string;
741
+ readonly adapter?: string;
742
+ })[],
743
+ ): DlqSummary {
744
+ inventories.forEach(validateDlqInventory);
745
+ const entries: DlqSummaryEntry[] = inventories.map((inventory) => {
746
+ const paths =
747
+ inventory.paths ??
748
+ getDlqTransitionPaths({
749
+ queueId: inventory.queueId,
750
+ owner: inventory.owner,
751
+ source: inventory.source,
752
+ });
753
+ validateDlqTransitionPaths(paths);
754
+ return {
755
+ id: inventory.queueId,
756
+ name: inventory.name,
757
+ owner: inventory.owner,
758
+ kind: inventory.kind,
759
+ source: inventory.source,
760
+ ...(inventory.adapter ? { adapter: inventory.adapter } : {}),
761
+ observedAt: inventory.observedAt,
762
+ unresolved: inventory.size,
763
+ oldestAgeMs: inventory.oldestAgeMs,
764
+ disposition: inventory.disposition,
765
+ capabilities: inventory.capabilities,
766
+ paths,
767
+ };
768
+ });
769
+ const unavailableQueues = entries.flatMap((entry) =>
770
+ entry.unresolved.kind === 'unavailable'
771
+ ? [{ id: entry.id, name: entry.name, reason: entry.unresolved.reason }]
772
+ : [],
773
+ );
774
+ const measured = entries.flatMap((entry) =>
775
+ entry.unresolved.kind === 'exact' || entry.unresolved.kind === 'lower_bound'
776
+ ? [entry.unresolved]
777
+ : [],
778
+ );
779
+ const knownTotal = measured.reduce((sum, metric) => sum + metric.value, 0);
780
+ const hasLowerBound = measured.some(
781
+ (metric) => metric.kind === 'lower_bound',
782
+ );
783
+ const total: DlqMeasurement =
784
+ measured.length === 0 && unavailableQueues.length > 0
785
+ ? {
786
+ kind: 'unavailable',
787
+ reason: 'No DLQ adapter produced a measurable snapshot.',
788
+ }
789
+ : hasLowerBound || unavailableQueues.length > 0
790
+ ? { kind: 'lower_bound', value: knownTotal }
791
+ : { kind: 'exact', value: knownTotal };
792
+ return {
793
+ pathIndexVersion: DLQ_PATH_INDEX_VERSION,
794
+ total,
795
+ queuesWithUnresolved: entries.filter(
796
+ (entry) => entry.disposition === 'open',
797
+ ).length,
798
+ queuesNeedingConfirmation: entries.filter(
799
+ (entry) => entry.disposition === 'uncertain',
800
+ ).length,
801
+ observedQueues: entries.filter(
802
+ (entry) => entry.unresolved.kind !== 'unavailable',
803
+ ).length,
804
+ unavailableQueues,
805
+ entries,
806
+ dispositionPolicy: {
807
+ kind: 'owner_defined',
808
+ label: 'Source-owned terminal state',
809
+ description:
810
+ 'Recovered, archived, released, or otherwise terminal rows leave the unresolved count. There is no cross-queue “mark done” mutation.',
811
+ },
812
+ };
813
+ }
814
+
815
+ /** Adapt the existing queue observation into the shared DLQ inventory port. */
816
+ export function dlqInventoryFromQueueObservation(input: {
817
+ queueId: string;
818
+ environment: string;
819
+ source: string;
820
+ observation: Pick<QueueObservation, 'observedAt' | 'depth' | 'oldestAgeMs'>;
821
+ capabilities?: readonly DlqActionCapability[];
822
+ paths?: readonly DlqTransitionPath[];
823
+ }): DlqInventory {
824
+ const size = input.observation.depth.deadLettered;
825
+ return {
826
+ contractVersion: DLQ_CONTRACT_VERSION,
827
+ queueId: input.queueId,
828
+ environment: input.environment,
829
+ source: input.source,
830
+ observedAt: input.observation.observedAt,
831
+ size,
832
+ oldestAgeMs: input.observation.oldestAgeMs.deadLettered,
833
+ disposition: dispositionForMeasurement(size),
834
+ capabilities: input.capabilities ?? [
835
+ {
836
+ action: 'inspect',
837
+ enabled: true,
838
+ reason: 'Read-only inventory is available.',
839
+ },
840
+ {
841
+ action: 'redrive',
842
+ enabled: false,
843
+ reason: 'The owning queue has not registered a fenced redrive path.',
844
+ },
845
+ {
846
+ action: 'discard',
847
+ enabled: false,
848
+ reason: 'Destructive discard is not a shared queue operation.',
849
+ },
850
+ {
851
+ action: 'mark_done',
852
+ enabled: false,
853
+ reason:
854
+ 'Completion is source-owned; a global manual close would hide durable work.',
855
+ },
856
+ ],
857
+ ...(input.paths ? { paths: input.paths } : {}),
858
+ };
859
+ }
860
+
861
+ export function validateDlqInventory(inventory: DlqInventory): void {
862
+ if (inventory.contractVersion !== DLQ_CONTRACT_VERSION) {
863
+ throw new Error(
864
+ `Unsupported DLQ contract version: ${String(inventory.contractVersion)}`,
865
+ );
866
+ }
867
+ if (!inventory.queueId.trim()) throw new Error('DLQ queueId is required.');
868
+ if (!inventory.environment.trim())
869
+ throw new Error('DLQ environment is required.');
870
+ if (!inventory.source.trim()) throw new Error('DLQ source is required.');
871
+ if (!Number.isFinite(inventory.observedAt) || inventory.observedAt <= 0) {
872
+ throw new Error('DLQ observedAt must be a positive timestamp.');
873
+ }
874
+ for (const [label, metric] of [
875
+ ['size', inventory.size],
876
+ ['oldestAgeMs', inventory.oldestAgeMs],
877
+ ] as const) {
878
+ if (
879
+ (metric.kind === 'exact' || metric.kind === 'lower_bound') &&
880
+ (!Number.isFinite(metric.value) || metric.value < 0)
881
+ ) {
882
+ throw new Error(`DLQ ${label} must be a finite non-negative number.`);
883
+ }
884
+ }
885
+ if (inventory.disposition !== dispositionForMeasurement(inventory.size)) {
886
+ throw new Error('DLQ disposition does not match its size measurement.');
887
+ }
888
+ if (inventory.paths) validateDlqTransitionPaths(inventory.paths);
889
+ }
890
+
891
+ /** A plan-only adapter for queues whose owner has not exposed a mutation port. */
892
+ export function readOnlyDlqAdapter(input: {
893
+ queueId: string;
894
+ source: string;
895
+ inspectInventory: (context: DlqAdapterContext) => Promise<DlqInventory>;
896
+ }): DlqAdapter {
897
+ return {
898
+ id: `readonly:${input.queueId}`,
899
+ queueId: input.queueId,
900
+ source: input.source,
901
+ inspect: async (context) => {
902
+ const inventory = await input.inspectInventory(context);
903
+ validateDlqInventory(inventory);
904
+ return inventory;
905
+ },
906
+ plan: async (context, request) => {
907
+ const snapshot = await input.inspectInventory(context);
908
+ validateDlqInventory(snapshot);
909
+ return {
910
+ contractVersion: DLQ_CONTRACT_VERSION,
911
+ queueId: input.queueId,
912
+ environment: context.environment,
913
+ action: request.action,
914
+ itemIds: request.itemIds ?? [],
915
+ snapshot,
916
+ allowed: false,
917
+ requiresApproval: true,
918
+ reason:
919
+ 'This queue is inspect-only until its owner registers a fenced mutation adapter.',
920
+ };
921
+ },
922
+ apply: async (_context, plan) => {
923
+ if (plan.allowed) {
924
+ throw new Error(
925
+ 'Read-only DLQ adapter cannot apply an allowed mutation plan.',
926
+ );
927
+ }
928
+ return {
929
+ contractVersion: DLQ_CONTRACT_VERSION,
930
+ queueId: plan.queueId,
931
+ environment: plan.environment,
932
+ action: plan.action,
933
+ applied: [],
934
+ skipped: plan.itemIds.map((id) => ({
935
+ id,
936
+ reason: 'No mutation adapter is registered for this queue.',
937
+ })),
938
+ verificationRequired: false,
939
+ };
940
+ },
941
+ };
942
+ }