@openlup/platform 0.22.0 → 0.24.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.
Files changed (241) hide show
  1. package/CHANGELOG.md +440 -158
  2. package/README.md +141 -589
  3. package/dist/fulfillment/adapterConformance.js +1 -1
  4. package/dist/fulfillment/bindings.js +1 -1
  5. package/dist/fulfillment/contracts/conformance.d.ts +3 -1
  6. package/dist/fulfillment/contracts/dispatch.d.ts +45 -3
  7. package/dist/fulfillment/contracts/index.d.ts +3 -3
  8. package/dist/fulfillment/contracts/observation.d.ts +21 -1
  9. package/dist/fulfillment/contracts/readback.d.ts +21 -0
  10. package/dist/fulfillment/contracts/store.d.ts +3 -0
  11. package/dist/fulfillment/contracts/sync.d.ts +58 -2
  12. package/dist/fulfillment/dispatchProjection.d.ts +20 -1
  13. package/dist/fulfillment/dispatchProjection.js +97 -6
  14. package/dist/fulfillment/index.d.ts +3 -2
  15. package/dist/fulfillment/index.js +2 -2
  16. package/dist/fulfillment/migrations.d.ts +5 -0
  17. package/dist/fulfillment/migrations.js +1 -0
  18. package/dist/fulfillment/observationProjection.d.ts +24 -3
  19. package/dist/fulfillment/observationProjection.js +65 -12
  20. package/dist/fulfillment/omnipack/client.d.ts +3 -1
  21. package/dist/fulfillment/omnipack/client.js +31 -13
  22. package/dist/fulfillment/omnipack/index.d.ts +4 -1
  23. package/dist/fulfillment/omnipack/index.js +107 -45
  24. package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
  25. package/dist/fulfillment/omnipack/mappers.js +24 -2
  26. package/dist/fulfillment/omnipack/observation.d.ts +11 -4
  27. package/dist/fulfillment/omnipack/observation.js +44 -29
  28. package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
  29. package/dist/fulfillment/omnipack/reconciliation.js +55 -0
  30. package/dist/fulfillment/postgresDispatchStore.js +1 -1
  31. package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
  32. package/dist/fulfillment/postgresShipmentSpine.js +17 -3
  33. package/dist/fulfillment/postgresStockSyncPort.js +6 -3
  34. package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
  35. package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
  36. package/dist/fulfillment/providerDispatchWorker.d.ts +51 -0
  37. package/dist/fulfillment/providerDispatchWorker.js +65 -20
  38. package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
  39. package/dist/fulfillment/providerReconciliationWorker.js +75 -19
  40. package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
  41. package/dist/fulfillment/providerStockSyncWorker.js +229 -120
  42. package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
  43. package/dist/fulfillment/verifiedIngress.d.ts +38 -11
  44. package/dist/fulfillment/verifiedIngress.js +36 -8
  45. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  46. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  47. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  48. package/dist/invoicing/accountingLineAllocation.js +69 -0
  49. package/dist/invoicing/adapterConformance.d.ts +42 -0
  50. package/dist/invoicing/adapterConformance.js +296 -0
  51. package/dist/invoicing/artifacts.d.ts +7 -0
  52. package/dist/invoicing/artifacts.js +20 -0
  53. package/dist/invoicing/bindings.d.ts +46 -0
  54. package/dist/invoicing/bindings.js +69 -0
  55. package/dist/invoicing/capabilities.d.ts +11 -0
  56. package/dist/invoicing/capabilities.js +10 -0
  57. package/dist/invoicing/channelPolicy.d.ts +28 -0
  58. package/dist/invoicing/channelPolicy.js +27 -0
  59. package/dist/invoicing/continuity.d.ts +12 -0
  60. package/dist/invoicing/continuity.js +18 -0
  61. package/dist/invoicing/contracts/capabilities.d.ts +40 -0
  62. package/dist/invoicing/contracts/capabilities.js +1 -0
  63. package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
  64. package/dist/invoicing/contracts/companyLookup.js +1 -0
  65. package/dist/invoicing/contracts/delivery.d.ts +74 -0
  66. package/dist/invoicing/contracts/delivery.js +1 -0
  67. package/dist/invoicing/contracts/facts.d.ts +110 -0
  68. package/dist/invoicing/contracts/facts.js +1 -0
  69. package/dist/invoicing/contracts/host.d.ts +245 -0
  70. package/dist/invoicing/contracts/host.js +1 -0
  71. package/dist/invoicing/contracts/index.d.ts +16 -0
  72. package/dist/invoicing/contracts/index.js +1 -0
  73. package/dist/invoicing/contracts/provider.d.ts +126 -0
  74. package/dist/invoicing/contracts/provider.js +1 -0
  75. package/dist/invoicing/contracts/settlement.d.ts +66 -0
  76. package/dist/invoicing/contracts/settlement.js +1 -0
  77. package/dist/invoicing/contracts/store.d.ts +277 -0
  78. package/dist/invoicing/contracts/store.js +1 -0
  79. package/dist/invoicing/contribution.d.ts +21 -0
  80. package/dist/invoicing/contribution.js +19 -0
  81. package/dist/invoicing/correction.d.ts +42 -0
  82. package/dist/invoicing/correction.js +44 -0
  83. package/dist/invoicing/delivery.d.ts +85 -0
  84. package/dist/invoicing/delivery.js +79 -0
  85. package/dist/invoicing/documentHistory.d.ts +50 -0
  86. package/dist/invoicing/documentHistory.js +213 -0
  87. package/dist/invoicing/documentSync.d.ts +24 -0
  88. package/dist/invoicing/documentSync.js +26 -0
  89. package/dist/invoicing/dutyCollector.d.ts +15 -0
  90. package/dist/invoicing/dutyCollector.js +35 -0
  91. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  92. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  93. package/dist/invoicing/handoff.d.ts +37 -0
  94. package/dist/invoicing/handoff.js +62 -0
  95. package/dist/invoicing/index.d.ts +39 -0
  96. package/dist/invoicing/index.js +37 -0
  97. package/dist/invoicing/issue.d.ts +39 -0
  98. package/dist/invoicing/issue.js +55 -0
  99. package/dist/invoicing/migrations.d.ts +48 -0
  100. package/dist/invoicing/migrations.js +25 -0
  101. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  102. package/dist/invoicing/nativeUnsupported.js +22 -0
  103. package/dist/invoicing/paymentReadback.d.ts +20 -0
  104. package/dist/invoicing/paymentReadback.js +47 -0
  105. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  106. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  107. package/dist/invoicing/readModel.d.ts +74 -0
  108. package/dist/invoicing/readModel.js +90 -0
  109. package/dist/invoicing/readback.d.ts +4 -0
  110. package/dist/invoicing/readback.js +30 -0
  111. package/dist/invoicing/reversal.d.ts +37 -0
  112. package/dist/invoicing/reversal.js +28 -0
  113. package/dist/invoicing/sellerRevision.d.ts +19 -0
  114. package/dist/invoicing/sellerRevision.js +14 -0
  115. package/dist/invoicing/settlementSync.d.ts +18 -0
  116. package/dist/invoicing/settlementSync.js +32 -0
  117. package/dist/invoicing/snapshot.d.ts +52 -0
  118. package/dist/invoicing/snapshot.js +141 -0
  119. package/dist/subscriptions/automaticRenewal.d.ts +44 -0
  120. package/dist/subscriptions/automaticRenewal.js +81 -0
  121. package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
  122. package/dist/subscriptions/automaticRenewalExecution.js +44 -0
  123. package/dist/subscriptions/contracts/commands.d.ts +115 -0
  124. package/dist/subscriptions/contracts/commands.js +1 -0
  125. package/dist/subscriptions/contracts/dunning.d.ts +133 -0
  126. package/dist/subscriptions/contracts/dunning.js +1 -0
  127. package/dist/subscriptions/contracts/index.d.ts +6 -0
  128. package/dist/subscriptions/contracts/index.js +1 -0
  129. package/dist/subscriptions/contracts/messages.d.ts +43 -0
  130. package/dist/subscriptions/contracts/messages.js +1 -0
  131. package/dist/subscriptions/contracts/renewal.d.ts +533 -0
  132. package/dist/subscriptions/contracts/renewal.js +1 -0
  133. package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
  134. package/dist/subscriptions/contracts/stockWait.js +1 -0
  135. package/dist/subscriptions/index.d.ts +39 -0
  136. package/dist/subscriptions/index.js +17 -0
  137. package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
  138. package/dist/subscriptions/paymentOutcomes.js +27 -0
  139. package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
  140. package/dist/subscriptions/postgresRenewalStore.js +61 -0
  141. package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
  142. package/dist/subscriptions/postgresRetryStore.js +142 -0
  143. package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
  144. package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
  145. package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
  146. package/dist/subscriptions/renewalCycleMechanism.js +157 -0
  147. package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
  148. package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
  149. package/dist/subscriptions/renewalIdentity.d.ts +3 -0
  150. package/dist/subscriptions/renewalIdentity.js +4 -0
  151. package/package.json +23 -3
  152. package/release-gates.json +74 -7
  153. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  154. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  155. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  156. package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
  157. package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
  158. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  159. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  160. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  161. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  162. package/src/fulfillment/adapterConformance.ts +1 -1
  163. package/src/fulfillment/bindings.ts +1 -1
  164. package/src/fulfillment/contracts/conformance.ts +3 -1
  165. package/src/fulfillment/contracts/dispatch.ts +24 -5
  166. package/src/fulfillment/contracts/index.ts +3 -3
  167. package/src/fulfillment/contracts/observation.ts +11 -3
  168. package/src/fulfillment/contracts/readback.ts +13 -0
  169. package/src/fulfillment/contracts/store.ts +1 -0
  170. package/src/fulfillment/contracts/sync.ts +13 -2
  171. package/src/fulfillment/dispatchProjection.ts +94 -7
  172. package/src/fulfillment/index.ts +3 -2
  173. package/src/fulfillment/migrations.ts +1 -0
  174. package/src/fulfillment/observationProjection.ts +64 -13
  175. package/src/fulfillment/omnipack/client.ts +32 -13
  176. package/src/fulfillment/omnipack/index.ts +99 -39
  177. package/src/fulfillment/omnipack/mappers.ts +23 -2
  178. package/src/fulfillment/omnipack/observation.ts +45 -27
  179. package/src/fulfillment/omnipack/reconciliation.ts +56 -0
  180. package/src/fulfillment/postgresDispatchStore.ts +1 -1
  181. package/src/fulfillment/postgresShipmentSpine.ts +21 -4
  182. package/src/fulfillment/postgresStockSyncPort.ts +6 -3
  183. package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
  184. package/src/fulfillment/providerDispatchWorker.ts +61 -20
  185. package/src/fulfillment/providerReconciliationWorker.ts +81 -25
  186. package/src/fulfillment/providerStockSyncWorker.ts +222 -126
  187. package/src/fulfillment/providerTrackingReferences.ts +3 -2
  188. package/src/fulfillment/verifiedIngress.ts +44 -11
  189. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  190. package/src/invoicing/accountingLineAllocation.ts +69 -0
  191. package/src/invoicing/adapterConformance.ts +249 -0
  192. package/src/invoicing/artifacts.ts +19 -0
  193. package/src/invoicing/bindings.ts +80 -0
  194. package/src/invoicing/capabilities.ts +9 -0
  195. package/src/invoicing/channelPolicy.ts +50 -0
  196. package/src/invoicing/continuity.ts +15 -0
  197. package/src/invoicing/contracts/capabilities.ts +15 -0
  198. package/src/invoicing/contracts/companyLookup.ts +13 -0
  199. package/src/invoicing/contracts/delivery.ts +24 -0
  200. package/src/invoicing/contracts/facts.ts +71 -0
  201. package/src/invoicing/contracts/host.ts +84 -0
  202. package/src/invoicing/contracts/index.ts +16 -0
  203. package/src/invoicing/contracts/provider.ts +78 -0
  204. package/src/invoicing/contracts/settlement.ts +39 -0
  205. package/src/invoicing/contracts/store.ts +102 -0
  206. package/src/invoicing/contribution.ts +28 -0
  207. package/src/invoicing/correction.ts +56 -0
  208. package/src/invoicing/delivery.ts +122 -0
  209. package/src/invoicing/documentHistory.ts +301 -0
  210. package/src/invoicing/documentSync.ts +51 -0
  211. package/src/invoicing/dutyCollector.ts +31 -0
  212. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  213. package/src/invoicing/handoff.ts +71 -0
  214. package/src/invoicing/index.ts +39 -0
  215. package/src/invoicing/issue.ts +64 -0
  216. package/src/invoicing/migrations.ts +25 -0
  217. package/src/invoicing/nativeUnsupported.ts +24 -0
  218. package/src/invoicing/paymentReadback.ts +70 -0
  219. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  220. package/src/invoicing/readModel.ts +80 -0
  221. package/src/invoicing/readback.ts +26 -0
  222. package/src/invoicing/reversal.ts +34 -0
  223. package/src/invoicing/sellerRevision.ts +20 -0
  224. package/src/invoicing/settlementSync.ts +37 -0
  225. package/src/invoicing/snapshot.ts +192 -0
  226. package/src/subscriptions/automaticRenewal.ts +87 -0
  227. package/src/subscriptions/automaticRenewalExecution.ts +39 -0
  228. package/src/subscriptions/contracts/commands.ts +46 -0
  229. package/src/subscriptions/contracts/dunning.ts +48 -0
  230. package/src/subscriptions/contracts/index.ts +6 -0
  231. package/src/subscriptions/contracts/messages.ts +18 -0
  232. package/src/subscriptions/contracts/renewal.ts +325 -0
  233. package/src/subscriptions/contracts/stockWait.ts +34 -0
  234. package/src/subscriptions/index.ts +24 -0
  235. package/src/subscriptions/paymentOutcomes.ts +23 -0
  236. package/src/subscriptions/postgresRenewalStore.ts +76 -0
  237. package/src/subscriptions/postgresRetryStore.ts +174 -0
  238. package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
  239. package/src/subscriptions/renewalCycleMechanism.ts +225 -0
  240. package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
  241. package/src/subscriptions/renewalIdentity.ts +5 -0
@@ -1,11 +1,14 @@
1
1
  import { aggregateAtpCompatibleStock, emptyLocal, stockMovementWindow } from "./providerStockAvailability.js";
2
- import type { FulfillmentStockSyncPort, FulfillmentStockSyncProvider, LocalStock, StockSnapshot, StockMovement } from "./contracts/index.js";
2
+ import type { FulfillmentStockSyncPort, FulfillmentStockSyncProvider, LocalStock, StockSnapshot, StockMovement, SyncCursor } from "./contracts/index.js";
3
3
  type Threshold = Parameters<FulfillmentStockSyncPort["resolveLowStockEvidence"]>[0]["activeThresholdKinds"][number];
4
4
  interface StockSyncResult {
5
5
  ok: boolean; checked: number; updated: number; mismatches: number; reservationCoverage: number;
6
6
  unclassified: number; lowStock: number; movements: number; replayed: number; failures: number;
7
7
  providerCalls: number; readBacks: number; skipped: boolean; syncRunId: string;
8
8
  lastMovementOccurredAt: string | null; reason?: string;
9
+ rejected: number; items: NonNullable<SyncCursor["items"]>[number][];
10
+ providerError?: NonNullable<SyncCursor["error"]>["providerError"];
11
+ refusal?: NonNullable<SyncCursor["refusal"]>;
9
12
  }
10
13
  export async function runFulfillmentStockSyncWorker(input: {
11
14
  port: FulfillmentStockSyncPort;
@@ -26,6 +29,8 @@ export async function runFulfillmentStockSyncWorker(input: {
26
29
  const syncRunId = input.compatibility.syncRunId(now);
27
30
  const result: StockSyncResult = {
28
31
  ok: true,
32
+ rejected: 0,
33
+ items: [],
29
34
  checked: 0,
30
35
  updated: 0,
31
36
  mismatches: 0,
@@ -42,152 +47,218 @@ export async function runFulfillmentStockSyncWorker(input: {
42
47
  lastMovementOccurredAt: null,
43
48
  };
44
49
 
45
- const previousCursor = await input.port.readCursor();
46
-
47
- // Stale-sync alert: if the last successful sync aged past the threshold, open a stale_sync
48
- // low-stock evidence row (evidence/warn — never blocks sales); a healthy run resolves it
49
- // below. A never-synced state (no prior cursor) is not "stale".
50
- const lastSyncedMs = previousCursor?.lastStockSyncedAt ? Date.parse(previousCursor.lastStockSyncedAt) : null;
51
- const staleGapHours =
52
- lastSyncedMs !== null && !Number.isNaN(lastSyncedMs) ? (now.getTime() - lastSyncedMs) / 3_600_000 : null;
53
- const syncIsStale = staleGapHours !== null && staleGapHours >= staleThresholdHours;
54
- if (syncIsStale) {
55
- await input.port.recordLowStockEvidence({
56
- requestKey: input.compatibility.staleKey(previousCursor!.lastStockSyncedAt!),
57
- skuRef: input.compatibility.staleEvidenceSku,
58
- thresholdKind: "stale_sync",
59
- severity: "warning",
60
- providerForSaleQuantity: 0,
61
- localAvailableQuantity: 0,
50
+ const alerts: NonNullable<SyncCursor["alerts"]>[number][] = [];
51
+ let stage = "cursor_read_failed";
52
+ let operationalReason: string | undefined;
53
+ let staleSync: SyncCursor["staleSync"];
54
+ let recoverStaleSync = false;
55
+ let previousCursor: SyncCursor | null = null;
56
+ let rejectedStockResponse = false;
57
+ const currentCoverageGapSkus = new Set<string>();
58
+ try {
59
+ previousCursor = await input.port.readCursor();
60
+
61
+ // Stale-sync alert: if the last successful sync aged past the threshold, open a stale_sync
62
+ // low-stock evidence row (evidence/warn — never blocks sales); a healthy run resolves it
63
+ // below. A never-synced state (no prior cursor) is not "stale".
64
+ const lastSyncedMs = previousCursor?.lastStockSyncedAt ? Date.parse(previousCursor.lastStockSyncedAt) : null;
65
+ const staleGapHours =
66
+ lastSyncedMs !== null && !Number.isNaN(lastSyncedMs) ? (now.getTime() - lastSyncedMs) / 3_600_000 : null;
67
+ const syncIsStale = staleGapHours !== null && staleGapHours >= staleThresholdHours;
68
+ recoverStaleSync = !syncIsStale;
69
+ if (syncIsStale) {
70
+ staleSync = { gapHours: Math.round(staleGapHours! * 10) / 10, thresholdHours: staleThresholdHours, detectedAt: now.toISOString() };
71
+ stage = "stock_evidence_write_failed";
72
+ alerts.push({ skuRef: input.compatibility.staleEvidenceSku, thresholdKind: "stale_sync", severity: "warning",
73
+ providerForSaleQuantity: 0, localAvailableQuantity: 0, ...staleSync });
74
+ await input.port.recordLowStockEvidence({
75
+ requestKey: input.compatibility.staleKey(previousCursor!.lastStockSyncedAt!),
76
+ skuRef: input.compatibility.staleEvidenceSku,
77
+ thresholdKind: "stale_sync",
78
+ severity: "warning",
79
+ providerForSaleQuantity: 0,
80
+ localAvailableQuantity: 0,
81
+ syncRunId,
82
+ });
83
+ }
84
+
85
+ stage = "cursor_write_failed";
86
+ await input.port.recordCursor({
62
87
  syncRunId,
88
+ status: "running",
89
+ lastStockSyncedAt: null,
90
+ lastMovementOccurredAt: null,
91
+ continuation: { nextRef: null, startLocalDate: null, endLocalDate: null },
92
+ coverageGapSkus: previousCursor?.coverageGapSkus ?? [], refusal: null,
63
93
  });
64
- }
65
94
 
66
- await input.port.recordCursor({
67
- status: "running",
68
- lastStockSyncedAt: null,
69
- lastMovementOccurredAt: null,
70
- continuation: { nextRef: null, startLocalDate: null, endLocalDate: null },
71
- coverageGapSkus: [], refusal: null,
72
- });
95
+ stage = "stock_read_failed";
96
+ result.providerCalls += 1;
97
+ const [localStock, providerStock] = await Promise.all([
98
+ input.port.readLocalInventoryStock(now.toISOString()),
99
+ input.provider.getStock(),
100
+ ]);
101
+ const stockItems: { stock: StockSnapshot; itemIndex: number }[] = [];
102
+ for (const [itemIndex, item] of providerStock.entries()) {
103
+ if ("kind" in item && item.kind === "rejected") {
104
+ result.rejected += 1;
105
+ result.failures += 1;
106
+ result.items.push({ kind: "stock", itemIndex: item.itemIndex, skuRef: null, status: "rejected", reason: item.reason });
107
+ } else if ("skuRef" in item && item.skuRef.trim()) {
108
+ stockItems.push({ stock: item, itemIndex });
109
+ }
110
+ }
111
+ const skus = [...new Set(stockItems.map(({ stock }) => stock.skuRef))];
112
+ rejectedStockResponse = providerStock.length > 0 && stockItems.length === 0;
113
+ stage = "stock_inventory_read_failed";
114
+ const [inventoryClasses, activeReservations] = await Promise.all([
115
+ input.port.readSkuInventoryClasses(skus),
116
+ input.port.readActiveReservations(skus, now.toISOString()),
117
+ ]);
73
118
 
74
- const [localStock, providerStock] = await Promise.all([
75
- input.port.readLocalInventoryStock(now.toISOString()),
76
- input.provider.getStock(),
77
- ]);
78
- result.providerCalls += 1;
79
-
80
- const stockItems = providerStock.filter((item) => item.skuRef.trim());
81
- const skus = [...new Set(stockItems.map((item) => item.skuRef))];
82
- const [inventoryClasses, activeReservations] = await Promise.all([
83
- input.port.readSkuInventoryClasses(skus),
84
- input.port.readActiveReservations(skus, now.toISOString()),
85
- ]);
86
-
87
- const movements = await input.provider.getStockMovements({ ...stockMovementWindow(previousCursor, now, input.compatibility.timeZone), size: input.movementBatchSize });
88
- result.providerCalls += 1;
89
- result.movements = movements.length;
90
- result.lastMovementOccurredAt = latestOccurredAt(movements);
91
-
92
- const localBySku = aggregateAtpCompatibleStock(localStock, now);
93
- const coverageCandidates: Array<{
94
- stock: StockSnapshot;
95
- local: LocalStock;
96
- activeReserved: number;
97
- }> = [];
98
- const currentCoverageGapSkus = new Set<string>();
99
- const packagingSkus = new Set<string>();
100
- const previousCoverageGapSkus = previousCursor?.status === "succeeded"
101
- ? readStringSet(previousCursor.coverageGapSkus)
102
- : new Set<string>();
119
+ stage = "stock_movements_read_failed";
120
+ result.providerCalls += 1;
121
+ const movementRows = await input.provider.getStockMovements({ ...stockMovementWindow(previousCursor, now, input.compatibility.timeZone), size: input.movementBatchSize });
122
+ const movements: StockMovement[] = [];
123
+ for (const item of movementRows) {
124
+ if ("kind" in item && item.kind === "rejected") {
125
+ result.rejected += 1;
126
+ result.failures += 1;
127
+ result.items.push({ kind: "movement", itemIndex: item.itemIndex, skuRef: null, status: "rejected", reason: item.reason });
128
+ } else if ("skuRef" in item) {
129
+ movements.push(item);
130
+ }
131
+ }
132
+ if (rejectedStockResponse || (result.rejected > 0 && stockItems.length + movements.length === 0)) {
133
+ result.ok = false;
134
+ result.reason = "invalid_sku_ref";
135
+ }
136
+ result.movements = movements.length;
137
+ result.lastMovementOccurredAt = latestOccurredAt(movements);
103
138
 
104
- for (const stock of stockItems) {
105
- try {
106
- const local = localBySku.get(stock.skuRef) ?? emptyLocal(stock.skuRef);
107
- const inventoryClass = inventoryClasses.get(stock.skuRef) ?? null;
108
- const activeReserved = activeReservations.get(stock.skuRef) ?? 0;
109
- const staleAfter = new Date(now.getTime() + staleThresholdHours * 3_600_000).toISOString();
110
- const current = await input.port.recordProviderStockCurrent({
111
- requestKey: input.compatibility.currentKey(syncRunId, stock.skuRef),
112
- snapshot: stock, inventoryClass, lastSyncedAt: now.toISOString(), staleAfter,
113
- });
114
- const snapshot = await input.port.recordStockSnapshot({
115
- requestKey: input.compatibility.snapshotKey(syncRunId, stock.skuRef),
116
- snapshot: stock, local, mismatchKind: "none", inventoryClass,
117
- });
139
+ const localBySku = aggregateAtpCompatibleStock(localStock, now);
140
+ const coverageCandidates: Array<{
141
+ stock: StockSnapshot;
142
+ local: LocalStock;
143
+ activeReserved: number;
144
+ }> = [];
145
+ const packagingSkus = new Set<string>();
146
+ const previousCoverageGapSkus = previousCursor?.status === "succeeded"
147
+ ? readStringSet(previousCursor.coverageGapSkus)
148
+ : new Set<string>();
149
+
150
+ for (const { stock, itemIndex } of stockItems) {
151
+ try {
152
+ const local = localBySku.get(stock.skuRef) ?? emptyLocal(stock.skuRef);
153
+ const inventoryClass = inventoryClasses.get(stock.skuRef) ?? null;
154
+ const activeReserved = activeReservations.get(stock.skuRef) ?? 0;
155
+ const staleAfter = new Date(now.getTime() + staleThresholdHours * 3_600_000).toISOString();
156
+ stage = "stock_current_write_failed";
157
+ const current = await input.port.recordProviderStockCurrent({
158
+ requestKey: input.compatibility.currentKey(syncRunId, stock.skuRef),
159
+ snapshot: stock, inventoryClass, lastSyncedAt: now.toISOString(), staleAfter,
160
+ });
161
+ stage = "snapshot_write_failed";
162
+ const snapshot = await input.port.recordStockSnapshot({
163
+ requestKey: input.compatibility.snapshotKey(syncRunId, stock.skuRef),
164
+ snapshot: stock, local, mismatchKind: "none", inventoryClass,
165
+ });
118
166
 
119
- result.checked += 1;
120
- result.updated += (snapshot.replayed ? 0 : 1) + (current.replayed ? 0 : 1);
121
- if (snapshot.replayed) result.replayed += 1;
122
- if (current.replayed) result.replayed += 1;
123
- if (snapshot.readBack) result.readBacks += 1;
124
- if (current.readBack) result.readBacks += 1;
125
- if (inventoryClass === null) result.unclassified += 1;
126
- if (inventoryClass === "packaging") packagingSkus.add(stock.skuRef);
127
- if (inventoryClass === "sellable") {
128
- coverageCandidates.push({ stock, local, activeReserved });
129
- if (activeReserved > stock.forSaleQuantity) currentCoverageGapSkus.add(stock.skuRef);
167
+ result.items.push({ itemIndex, skuRef: stock.skuRef, status: "recorded", activeReserved,
168
+ sellableNow: inventoryClass === "sellable" ? Math.max(0, stock.forSaleQuantity - activeReserved - local.safetyStock) : null,
169
+ coverageGapQuantity: Math.max(0, activeReserved - stock.forSaleQuantity) });
170
+ result.checked += 1;
171
+ result.updated += (snapshot.replayed ? 0 : 1) + (current.replayed ? 0 : 1);
172
+ if (snapshot.replayed) result.replayed += 1;
173
+ if (current.replayed) result.replayed += 1;
174
+ if (snapshot.readBack) result.readBacks += 1;
175
+ if (current.readBack) result.readBacks += 1;
176
+ if (inventoryClass === null) result.unclassified += 1;
177
+ if (inventoryClass === "packaging") packagingSkus.add(stock.skuRef);
178
+ if (inventoryClass === "sellable") {
179
+ coverageCandidates.push({ stock, local, activeReserved });
180
+ if (activeReserved > stock.forSaleQuantity) currentCoverageGapSkus.add(stock.skuRef);
181
+ }
182
+ } catch (error) {
183
+ result.ok = false;
184
+ result.failures += 1;
185
+ const reason = safeReason(error, stage);
186
+ operationalReason ??= reason;
187
+ result.items.push({ itemIndex, skuRef: stock.skuRef, status: "failed", reason });
130
188
  }
131
- } catch (error) {
132
- result.ok = false;
133
- result.failures += 1;
134
- result.reason = result.reason ?? "stock_sync_operation_failed";
135
189
  }
136
- }
137
190
 
138
- if (result.ok) {
139
- for (const { stock, local, activeReserved } of coverageCandidates) {
140
- const localAvailable = availableQuantity(local);
141
- const confirmedCoverageGap = currentCoverageGapSkus.has(stock.skuRef) && previousCoverageGapSkus.has(stock.skuRef);
142
- const lowStockSignals = lowStockSignalsFor(stock, local, activeReserved, confirmedCoverageGap);
143
- const activeThresholdKinds: Threshold[] = lowStockSignals.map((signal) => signal.thresholdKind);
144
- if (currentCoverageGapSkus.has(stock.skuRef) && !activeThresholdKinds.includes("reservation_coverage")) {
145
- activeThresholdKinds.push("reservation_coverage");
191
+ if (result.ok) {
192
+ for (const { stock, local, activeReserved } of coverageCandidates) {
193
+ const localAvailable = availableQuantity(local);
194
+ const confirmedCoverageGap = currentCoverageGapSkus.has(stock.skuRef) && previousCoverageGapSkus.has(stock.skuRef);
195
+ const lowStockSignals = lowStockSignalsFor(stock, local, activeReserved, confirmedCoverageGap);
196
+ const activeThresholdKinds: Threshold[] = lowStockSignals.map((signal) => signal.thresholdKind);
197
+ if (currentCoverageGapSkus.has(stock.skuRef) && !activeThresholdKinds.includes("reservation_coverage")) {
198
+ activeThresholdKinds.push("reservation_coverage");
199
+ }
200
+ for (const signal of lowStockSignals) {
201
+ alerts.push({ skuRef: stock.skuRef, thresholdKind: signal.thresholdKind, severity: signal.severity,
202
+ providerForSaleQuantity: stock.forSaleQuantity, localAvailableQuantity: localAvailable, detectedAt: now.toISOString(),
203
+ activeReserved, coverageGapQuantity: Math.max(0, activeReserved - stock.forSaleQuantity) });
204
+ stage = "stock_evidence_write_failed";
205
+ const evidence = await input.port.recordLowStockEvidence({
206
+ requestKey: input.compatibility.lowStockKey(stock.skuRef, signal.thresholdKind),
207
+ skuRef: stock.skuRef, thresholdKind: signal.thresholdKind, severity: signal.severity,
208
+ providerForSaleQuantity: stock.forSaleQuantity, localAvailableQuantity: localAvailable, syncRunId,
209
+ });
210
+ if (evidence.replayed) result.replayed += 1;
211
+ result.lowStock += evidence.replayed ? 0 : 1;
212
+ if (signal.thresholdKind === "reservation_coverage") result.reservationCoverage += 1;
213
+ }
214
+ stage = "stock_evidence_resolve_failed";
215
+ await input.port.resolveLowStockEvidence({
216
+ skuRef: stock.skuRef,
217
+ activeThresholdKinds,
218
+ syncRunId,
219
+ });
146
220
  }
147
- for (const signal of lowStockSignals) {
148
- const evidence = await input.port.recordLowStockEvidence({
149
- requestKey: input.compatibility.lowStockKey(stock.skuRef, signal.thresholdKind),
150
- skuRef: stock.skuRef, thresholdKind: signal.thresholdKind, severity: signal.severity,
151
- providerForSaleQuantity: stock.forSaleQuantity, localAvailableQuantity: localAvailable, syncRunId,
221
+ for (const sku of packagingSkus) {
222
+ stage = "stock_evidence_resolve_failed";
223
+ await input.port.resolveLowStockEvidence({
224
+ skuRef: sku,
225
+ activeThresholdKinds: [],
226
+ syncRunId,
152
227
  });
153
- if (evidence.replayed) result.replayed += 1;
154
- result.lowStock += evidence.replayed ? 0 : 1;
155
- if (signal.thresholdKind === "reservation_coverage") result.reservationCoverage += 1;
156
228
  }
157
- await input.port.resolveLowStockEvidence({
158
- skuRef: stock.skuRef,
159
- activeThresholdKinds,
160
- syncRunId,
161
- });
162
- }
163
- for (const sku of packagingSkus) {
164
- await input.port.resolveLowStockEvidence({
165
- skuRef: sku,
166
- activeThresholdKinds: [],
167
- syncRunId,
168
- });
169
229
  }
230
+ } catch (error) {
231
+ result.ok = false;
232
+ result.failures += 1;
233
+ result.providerError = providerDiagnostic(error);
234
+ if (stage === "stock_read_failed" || stage === "stock_movements_read_failed") result.refusal = admissionRefusal(error);
235
+ operationalReason ??= result.providerError ? `provider_${result.providerError.operation}_failed:${result.providerError.code}` : result.refusal?.reason ?? safeReason(error, stage);
170
236
  }
171
237
 
238
+ result.reason = operationalReason ?? result.reason;
239
+ if (!result.ok) result.refusal ??= { reason: result.failures === result.rejected ? "malformed" : "io_failure", phase: "sync", retryable: result.providerError?.retryable ?? result.failures > result.rejected };
172
240
  await input.port.recordCursor({
173
- status: result.ok ? "succeeded" : "failed",
241
+ status: result.ok ? "succeeded" : "failed", syncRunId,
174
242
  lastStockSyncedAt: result.ok ? now.toISOString() : null,
175
243
  lastMovementOccurredAt: result.lastMovementOccurredAt,
176
244
  continuation: { nextRef: null, startLocalDate: null, endLocalDate: null },
177
- coverageGapSkus: result.ok ? [...currentCoverageGapSkus].sort() : [],
178
- refusal: result.ok ? null : { reason: "io_failure", phase: "sync", retryable: true },
245
+ coverageGapSkus: result.ok ? [...currentCoverageGapSkus].sort() : rejectedStockResponse ? previousCursor?.coverageGapSkus ?? [] : [],
246
+ refusal: result.refusal ?? null,
247
+ error: result.ok ? null : { reason: result.reason!, ...(result.providerError ? { providerError: result.providerError } : {}) },
248
+ counts: { checked: result.checked, updated: result.updated, movements: result.movements, rejected: result.rejected,
249
+ failures: result.failures, providerCalls: result.providerCalls, readBacks: result.readBacks },
250
+ items: result.items, alerts, staleSync,
179
251
  });
180
-
181
- // Recovery: a healthy run (succeeded, not itself stale) closes any open stale_sync row.
182
- if (result.ok && !syncIsStale) {
183
- await input.port.resolveLowStockEvidence({
184
- skuRef: input.compatibility.staleEvidenceSku,
185
- activeThresholdKinds: [],
186
- syncRunId,
187
-
188
- });
252
+ // Recovery follows the durable succeeded cursor; a failed cleanup leaves the completed run intact.
253
+ if (result.ok && recoverStaleSync) {
254
+ try {
255
+ await input.port.resolveLowStockEvidence({
256
+ skuRef: input.compatibility.staleEvidenceSku,
257
+ activeThresholdKinds: [],
258
+ syncRunId,
259
+ });
260
+ } catch { /* Best-effort cleanup must not undo the completed run. */ }
189
261
  }
190
-
191
262
  return result;
192
263
  }
193
264
 
@@ -220,3 +291,28 @@ function latestOccurredAt(movements: StockMovement[]): string | null {
220
291
  .filter((value): value is string => Boolean(value))
221
292
  .sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null;
222
293
  }
294
+
295
+ /** Keep the operation's closed identity and SQLSTATE only, never arbitrary exception prose. */
296
+ function safeReason(error: unknown, operation: string): string {
297
+ const code = error && typeof error === "object" && "code" in error ? error.code : null;
298
+ if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code)) return `${operation}:${code}`;
299
+ const known = error instanceof Error ? error.message.match(new RegExp(`^${operation}:([0-9A-Z]{5})$`)) : null;
300
+ return known ? `${operation}:${known[1]}` : operation;
301
+ }
302
+ function providerDiagnostic(error: unknown): StockSyncResult["providerError"] {
303
+ if (!error || typeof error !== "object" || !("providerError" in error)) return undefined;
304
+ const detail = error.providerError;
305
+ if (!detail || typeof detail !== "object" || !("operation" in detail) || !("code" in detail) || !("status" in detail) || !("retryable" in detail)) return undefined;
306
+ if (detail.operation !== "get_stock" && detail.operation !== "get_stock_movements") return undefined;
307
+ if (typeof detail.code !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(detail.code)) return undefined;
308
+ return { operation: detail.operation, code: detail.code,
309
+ status: typeof detail.status === "number" && Number.isInteger(detail.status) && detail.status >= 100 && detail.status <= 599 ? detail.status : null,
310
+ retryable: detail.retryable === true };
311
+ }
312
+
313
+ function admissionRefusal(error: unknown): StockSyncResult["refusal"] {
314
+ if (!error || typeof error !== "object" || !("reason" in error) || !("phase" in error) || !("retryable" in error)) return undefined;
315
+ if (error.phase !== "sync" || typeof error.retryable !== "boolean") return undefined;
316
+ if (error.reason !== "disabled" && error.reason !== "not_ready" && error.reason !== "malformed") return undefined;
317
+ return { reason: error.reason, phase: error.phase, retryable: error.retryable };
318
+ }
@@ -1,4 +1,4 @@
1
- import type { FulfillmentTrackingReference } from "./contracts/index.js";
1
+ import type { FulfillmentTrackingReference, FulfillmentObservationCodecs } from "./contracts/index.js";
2
2
 
3
3
  export function trackingReferenceComplete(ref: FulfillmentTrackingReference): boolean {
4
4
  return Boolean(ref.carrierRef && ref.publicUrl && ref.serviceRef);
@@ -7,7 +7,8 @@ export function trackingReferenceComplete(ref: FulfillmentTrackingReference): bo
7
7
  export function enrichTrackingReference(
8
8
  ref: FulfillmentTrackingReference,
9
9
  deliveryCarrier: { carrierRef: string | null; serviceRef: string | null } | null,
10
- codecs: {
10
+ codecs: FulfillmentObservationCodecs & {
11
+ approveTrackingUrl?(url: string): boolean;
11
12
  normalizeCarrier(input: { carrierRef: string | null; serviceRef: string | null }): string | null;
12
13
  publicUrl(carrierRef: string | null, trackingNumber: string): string | null;
13
14
  },
@@ -1,20 +1,35 @@
1
- import type { FulfillmentObservation, FulfillmentObservationPort, FulfillmentRefusal } from "./contracts/index.js";
1
+ import type { FulfillmentObservation, FulfillmentObservationCodecs, FulfillmentObservationPort, FulfillmentRefusal } from "./contracts/index.js";
2
+ import { safeDiagnosticCode } from "./dispatchProjection.js";
2
3
  import { applyFulfillmentObservationEffects } from "./fulfillmentObservationEffects.js";
3
4
  import { observationStatusPolicy, projectObservation } from "./observationProjection.js";
4
5
  import { uniqueTrackingReferences } from "./providerTrackingReferences.js";
5
6
 
6
7
  type EffectInput = Parameters<typeof applyFulfillmentObservationEffects<FulfillmentObservation["fact"]["canonicalStatus"], "in_transit" | "delivered">>[0];
7
8
  type DispatchRef = { orderRef: string; fulfillmentRef: string; dispatchRef: string; externalRef: string | null };
9
+ export interface FulfillmentIngressCounts { invalidTimestamps: number; rejectedCorrelationRefs: number; rejectedTrackingRefs: number; rejectedTrackingUrls: number }
10
+ export type FulfillmentIngressResult = { counts: FulfillmentIngressCounts } & (
11
+ | { kind: "refused"; receiptId: null; refusal: FulfillmentRefusal; diagnostics?: { invalidTimestamp?: true; rejectedCorrelationRefs?: number } }
12
+ | { kind: "processed" | "ignored" | "superseded"; receiptId: string; observation: FulfillmentObservation; replayed: boolean; trackingRefs: number; trackingReadBacks: number; supersededCause?: string });
13
+ /** Safe response classes only; the host owns HTTP and localized message text. */
14
+ export function fulfillmentIngressHttpResult(result: FulfillmentIngressResult): { status: 200 | 400 | 403 | 503; messageClass: "accepted" | "bad_request" | "forbidden" | "upstream_unavailable" } {
15
+ if (result.kind !== "refused") return { status: 200, messageClass: "accepted" };
16
+ if (result.refusal.reason === "untrusted") return { status: 403, messageClass: "forbidden" };
17
+ if (result.refusal.reason === "disabled" || result.refusal.reason === "not_ready" || result.refusal.retryable)
18
+ return { status: 503, messageClass: "upstream_unavailable" };
19
+ return { status: 400, messageClass: "bad_request" };
20
+ }
8
21
  /** Adapter verification owns auth-before-parse; the host owns durable replay. */
9
22
  export async function verifyAndNormalizeFulfillmentObservation(input: {
10
23
  enabled: boolean;
24
+ approveTrackingUrl?(url: string): boolean;
25
+ observationCodecs?: FulfillmentObservationCodecs;
11
26
  provider: FulfillmentObservationPort;
12
27
  verification: Parameters<FulfillmentObservationPort["verifyAndNormalize"]>[0];
13
28
  localPersistence: {
14
29
  /** G17: atomic insert by eventKey; retries keep the first stored firstReceivedAt. */
15
- recordObservation(observation: FulfillmentObservation): Promise<{ replayed: boolean }>;
30
+ recordObservation(observation: FulfillmentObservation): Promise<{ replayed: boolean; receiptId?: string }>;
16
31
  /** Idempotent; only called after completion or a classified superseding conflict. */
17
- markProcessed(eventKey: string, outcome: { status: "processed" | "ignored"; refusal?: FulfillmentRefusal }): Promise<void>;
32
+ markProcessed(eventKey: string, outcome: { status: "processed" | "ignored"; refusal?: FulfillmentRefusal; supersededCause?: string }): Promise<void>;
18
33
  };
19
34
  lifecycle: {
20
35
  findDispatchRef(observation: FulfillmentObservation): Promise<DispatchRef | null>;
@@ -23,19 +38,34 @@ export async function verifyAndNormalizeFulfillmentObservation(input: {
23
38
  effects: EffectInput["port"];
24
39
  keys: { stock(ref: string): string; handoff(ref: string): string; tracking(eventKey: string, number: string): string };
25
40
  issueAccountingInvoice?(ref: string): Promise<void>;
41
+ /** Return a machine identity, never exception prose. */
42
+ supersededCause?(error: unknown): string | null;
26
43
  classifyFailure(error: unknown): "stock_contention" | "state_conflict" | "other";
27
44
  annotateStockContention?(eventKey: string, fulfillmentRef: string): Promise<void>;
28
45
  };
29
- }): Promise<{ kind: "refused"; refusal: FulfillmentRefusal } | { kind: "processed" | "ignored" | "superseded"; observation: FulfillmentObservation; replayed: boolean; trackingRefs: number; trackingReadBacks: number }> {
30
- if (!input.enabled) return { kind: "refused", refusal: { reason: "disabled", phase: "verify", retryable: false } };
46
+ }): Promise<FulfillmentIngressResult> {
47
+ const counts: FulfillmentIngressCounts = { invalidTimestamps: 0, rejectedCorrelationRefs: 0, rejectedTrackingRefs: 0, rejectedTrackingUrls: 0 };
48
+ if (!input.enabled) return { kind: "refused", receiptId: null, counts, refusal: { reason: "disabled", phase: "verify", retryable: false } };
31
49
  const verified = await input.provider.verifyAndNormalize(input.verification);
32
- if (verified.kind === "refused") return verified;
33
- const observation = projectObservation(verified.observation);
50
+ if (verified.kind === "refused") {
51
+ if (verified.diagnostics?.invalidTimestamp) counts.invalidTimestamps++;
52
+ const rejected = verified.diagnostics?.rejectedCorrelationRefs;
53
+ if (typeof rejected === "number" && Number.isSafeInteger(rejected) && rejected > 0) counts.rejectedCorrelationRefs = rejected;
54
+ return { kind: "refused", receiptId: null, refusal: verified.refusal, counts,
55
+ ...(counts.invalidTimestamps || counts.rejectedCorrelationRefs ? { diagnostics: {
56
+ ...(counts.invalidTimestamps ? { invalidTimestamp: true as const } : {}),
57
+ ...(counts.rejectedCorrelationRefs ? { rejectedCorrelationRefs: counts.rejectedCorrelationRefs } : {}),
58
+ } } : {}) };
59
+ }
60
+ const observation = projectObservation(verified.observation, input.approveTrackingUrl, input.observationCodecs);
61
+ counts.rejectedTrackingRefs = observation.diagnostics?.admission?.rejectedTrackingRefs ?? 0;
62
+ counts.rejectedTrackingUrls = observation.diagnostics?.admission?.rejectedTrackingUrls ?? 0;
34
63
  const inbound = await input.localPersistence.recordObservation(observation);
64
+ const receiptId = inbound.receiptId ?? observation.eventKey;
35
65
  const ref = await input.lifecycle.findDispatchRef(observation);
36
66
  if (!ref) {
37
67
  await input.localPersistence.markProcessed(observation.eventKey, { status: "ignored", refusal: { reason: "local_missing", phase: "apply", retryable: false } });
38
- return { kind: "ignored", observation, replayed: inbound.replayed, trackingRefs: 0, trackingReadBacks: 0 };
68
+ return { kind: "ignored", receiptId, counts, observation, replayed: inbound.replayed, trackingRefs: 0, trackingReadBacks: 0 };
39
69
  }
40
70
  const externalRef = observation.correlation.externalRef?.trim() || ref.externalRef?.trim();
41
71
  if (!externalRef) throw new TypeError("fulfillment_dispatch_acceptance_external_ref_missing");
@@ -71,10 +101,13 @@ export async function verifyAndNormalizeFulfillmentObservation(input: {
71
101
  try { await applyFulfillmentObservationEffects(effects, observationStatusPolicy); }
72
102
  catch (error) {
73
103
  if (input.lifecycle.classifyFailure(error) !== "state_conflict") throw error;
74
- await input.localPersistence.markProcessed(observation.eventKey, { status: "processed" });
75
- return { kind: "superseded", observation, replayed: inbound.replayed || statusEvidence.replayed, trackingRefs, trackingReadBacks };
104
+ const cause = input.lifecycle.supersededCause ? input.lifecycle.supersededCause(error)
105
+ : error && typeof error === "object" && "reason" in error ? error.reason : null;
106
+ const supersededCause = safeDiagnosticCode(cause) ?? "fulfillment_state_conflict";
107
+ await input.localPersistence.markProcessed(observation.eventKey, { status: "processed", supersededCause });
108
+ return { kind: "superseded", receiptId, counts, supersededCause, observation, replayed: inbound.replayed || statusEvidence.replayed, trackingRefs, trackingReadBacks };
76
109
  }
77
110
  }
78
111
  await input.localPersistence.markProcessed(observation.eventKey, { status: "processed" });
79
- return { kind: "processed", observation, replayed: inbound.replayed || statusEvidence.replayed, trackingRefs, trackingReadBacks };
112
+ return { kind: "processed", receiptId, counts, observation, replayed: inbound.replayed || statusEvidence.replayed, trackingRefs, trackingReadBacks };
80
113
  }