@openlup/platform 0.22.0 → 0.23.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 (156) hide show
  1. package/CHANGELOG.md +222 -99
  2. package/README.md +59 -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 +41 -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 +95 -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 +17 -7
  22. package/dist/fulfillment/omnipack/index.d.ts +4 -1
  23. package/dist/fulfillment/omnipack/index.js +104 -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 +13 -0
  37. package/dist/fulfillment/providerDispatchWorker.js +47 -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/capabilities.d.ts +11 -0
  46. package/dist/invoicing/capabilities.js +10 -0
  47. package/dist/invoicing/contracts/capabilities.d.ts +40 -0
  48. package/dist/invoicing/contracts/capabilities.js +1 -0
  49. package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
  50. package/dist/invoicing/contracts/companyLookup.js +1 -0
  51. package/dist/invoicing/contracts/delivery.d.ts +74 -0
  52. package/dist/invoicing/contracts/delivery.js +1 -0
  53. package/dist/invoicing/contracts/facts.d.ts +100 -0
  54. package/dist/invoicing/contracts/facts.js +1 -0
  55. package/dist/invoicing/contracts/host.d.ts +246 -0
  56. package/dist/invoicing/contracts/host.js +1 -0
  57. package/dist/invoicing/contracts/index.d.ts +16 -0
  58. package/dist/invoicing/contracts/index.js +1 -0
  59. package/dist/invoicing/contracts/provider.d.ts +126 -0
  60. package/dist/invoicing/contracts/provider.js +1 -0
  61. package/dist/invoicing/contracts/settlement.d.ts +66 -0
  62. package/dist/invoicing/contracts/settlement.js +1 -0
  63. package/dist/invoicing/contracts/store.d.ts +267 -0
  64. package/dist/invoicing/contracts/store.js +1 -0
  65. package/dist/invoicing/index.d.ts +1 -0
  66. package/dist/invoicing/index.js +1 -0
  67. package/dist/subscriptions/automaticRenewal.d.ts +44 -0
  68. package/dist/subscriptions/automaticRenewal.js +81 -0
  69. package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
  70. package/dist/subscriptions/automaticRenewalExecution.js +44 -0
  71. package/dist/subscriptions/contracts/commands.d.ts +115 -0
  72. package/dist/subscriptions/contracts/commands.js +1 -0
  73. package/dist/subscriptions/contracts/dunning.d.ts +133 -0
  74. package/dist/subscriptions/contracts/dunning.js +1 -0
  75. package/dist/subscriptions/contracts/index.d.ts +6 -0
  76. package/dist/subscriptions/contracts/index.js +1 -0
  77. package/dist/subscriptions/contracts/messages.d.ts +43 -0
  78. package/dist/subscriptions/contracts/messages.js +1 -0
  79. package/dist/subscriptions/contracts/renewal.d.ts +533 -0
  80. package/dist/subscriptions/contracts/renewal.js +1 -0
  81. package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
  82. package/dist/subscriptions/contracts/stockWait.js +1 -0
  83. package/dist/subscriptions/index.d.ts +39 -0
  84. package/dist/subscriptions/index.js +17 -0
  85. package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
  86. package/dist/subscriptions/paymentOutcomes.js +27 -0
  87. package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
  88. package/dist/subscriptions/postgresRenewalStore.js +61 -0
  89. package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
  90. package/dist/subscriptions/postgresRetryStore.js +142 -0
  91. package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
  92. package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
  93. package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
  94. package/dist/subscriptions/renewalCycleMechanism.js +157 -0
  95. package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
  96. package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
  97. package/dist/subscriptions/renewalIdentity.d.ts +3 -0
  98. package/dist/subscriptions/renewalIdentity.js +4 -0
  99. package/package.json +22 -2
  100. package/release-gates.json +56 -3
  101. package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
  102. package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
  103. package/src/fulfillment/adapterConformance.ts +1 -1
  104. package/src/fulfillment/bindings.ts +1 -1
  105. package/src/fulfillment/contracts/conformance.ts +3 -1
  106. package/src/fulfillment/contracts/dispatch.ts +20 -5
  107. package/src/fulfillment/contracts/index.ts +3 -3
  108. package/src/fulfillment/contracts/observation.ts +11 -3
  109. package/src/fulfillment/contracts/readback.ts +13 -0
  110. package/src/fulfillment/contracts/store.ts +1 -0
  111. package/src/fulfillment/contracts/sync.ts +13 -2
  112. package/src/fulfillment/dispatchProjection.ts +92 -7
  113. package/src/fulfillment/index.ts +3 -2
  114. package/src/fulfillment/migrations.ts +1 -0
  115. package/src/fulfillment/observationProjection.ts +64 -13
  116. package/src/fulfillment/omnipack/client.ts +19 -7
  117. package/src/fulfillment/omnipack/index.ts +96 -39
  118. package/src/fulfillment/omnipack/mappers.ts +23 -2
  119. package/src/fulfillment/omnipack/observation.ts +45 -27
  120. package/src/fulfillment/omnipack/reconciliation.ts +56 -0
  121. package/src/fulfillment/postgresDispatchStore.ts +1 -1
  122. package/src/fulfillment/postgresShipmentSpine.ts +21 -4
  123. package/src/fulfillment/postgresStockSyncPort.ts +6 -3
  124. package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
  125. package/src/fulfillment/providerDispatchWorker.ts +48 -20
  126. package/src/fulfillment/providerReconciliationWorker.ts +81 -25
  127. package/src/fulfillment/providerStockSyncWorker.ts +222 -126
  128. package/src/fulfillment/providerTrackingReferences.ts +3 -2
  129. package/src/fulfillment/verifiedIngress.ts +44 -11
  130. package/src/invoicing/capabilities.ts +9 -0
  131. package/src/invoicing/contracts/capabilities.ts +15 -0
  132. package/src/invoicing/contracts/companyLookup.ts +13 -0
  133. package/src/invoicing/contracts/delivery.ts +24 -0
  134. package/src/invoicing/contracts/facts.ts +60 -0
  135. package/src/invoicing/contracts/host.ts +85 -0
  136. package/src/invoicing/contracts/index.ts +16 -0
  137. package/src/invoicing/contracts/provider.ts +78 -0
  138. package/src/invoicing/contracts/settlement.ts +39 -0
  139. package/src/invoicing/contracts/store.ts +98 -0
  140. package/src/invoicing/index.ts +1 -0
  141. package/src/subscriptions/automaticRenewal.ts +87 -0
  142. package/src/subscriptions/automaticRenewalExecution.ts +39 -0
  143. package/src/subscriptions/contracts/commands.ts +46 -0
  144. package/src/subscriptions/contracts/dunning.ts +48 -0
  145. package/src/subscriptions/contracts/index.ts +6 -0
  146. package/src/subscriptions/contracts/messages.ts +18 -0
  147. package/src/subscriptions/contracts/renewal.ts +325 -0
  148. package/src/subscriptions/contracts/stockWait.ts +34 -0
  149. package/src/subscriptions/index.ts +24 -0
  150. package/src/subscriptions/paymentOutcomes.ts +23 -0
  151. package/src/subscriptions/postgresRenewalStore.ts +76 -0
  152. package/src/subscriptions/postgresRetryStore.ts +174 -0
  153. package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
  154. package/src/subscriptions/renewalCycleMechanism.ts +225 -0
  155. package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
  156. package/src/subscriptions/renewalIdentity.ts +5 -0
@@ -1,16 +1,22 @@
1
+ import { admittedReference, reference } from "../dispatchProjection.js";
2
+ import { projectObservation, publicTrackingUrl } from "../observationProjection.js";
3
+ export { reference } from "../dispatchProjection.js";
1
4
  import { createHash } from "node:crypto";
2
5
  import { parseNormalizedFulfillmentFact } from "@openlup/core/fulfillment";
3
- import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentRefusal } from "../contracts/index.js";
6
+ import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentObservationCodecs, FulfillmentRefusal } from "../contracts/index.js";
4
7
  import type { OmnipackWebhookEvidence } from "./mappers.js";
5
8
  import type { OmnipackTrackingReferenceEvidence } from "./trackingReferences.js";
6
- import { EVENT_TO_STATUS, type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
9
+ import { buildProviderEventId, EVENT_TO_STATUS, type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
7
10
  import { localStatusFor } from "./omnipackStatusVocabulary.js";
8
11
 
9
12
  export const refusal = (reason: FulfillmentRefusal["reason"], phase: FulfillmentRefusal["phase"], retryable = false): FulfillmentRefusal => ({ reason, phase, retryable });
10
13
  export const digest = (bytes: string | Uint8Array): string => createHash("sha256").update(bytes).digest("hex");
11
- /** References are opaque identifiers, never arbitrary provider prose or URLs. */
12
- export function reference(value: string | null | undefined): string | null {
13
- return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value) ? value : null;
14
+ /** Share admission with the durable sink, including explicit deny allowlists. */
15
+ export function correlationReference(value: string | null | undefined, codecs: OmnipackObservationCodecs = {}): string | null {
16
+ return admittedReference(value, codecs.approveReference);
17
+ }
18
+ export interface OmnipackObservationCodecs extends FulfillmentObservationCodecs {
19
+ normalizeCarrier?(input: { carrierRef: string | null; serviceRef: string | null }): string | null;
14
20
  }
15
21
  export function timestamp(value: string | null): string | null {
16
22
  return value && /^\d{4}-\d\d-\d\dT/u.test(value) && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
@@ -18,44 +24,56 @@ export function timestamp(value: string | null): string | null {
18
24
  export function eventKind(value: string): OmnipackWebhookRouteEvent | null {
19
25
  return Object.hasOwn(EVENT_TO_STATUS, value) ? value as OmnipackWebhookRouteEvent : null;
20
26
  }
21
- export function publicUrl(value: string | null, approve: (url: string) => boolean): string | null {
22
- if (!value) return null;
23
- try {
24
- const url = new URL(value);
25
- // Query-free URLs only: signing tokens and credentials never enter durable tracking.
26
- return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && approve(url.href) ? url.href : null;
27
- } catch { return null; }
28
- }
29
- function tracking(refs: OmnipackTrackingReferenceEvidence[], approve: (url: string) => boolean): FulfillmentObservation["trackingRefs"] {
30
- return refs.flatMap(item => {
31
- const trackingNumber = reference(item.trackingNumber);
32
- return trackingNumber ? [{ trackingNumber, carrierRef: reference(item.carrier), serviceRef: reference(item.service), publicUrl: publicUrl(item.trackingUrl, approve) }] : [];
33
- });
27
+ function tracking(refs: OmnipackTrackingReferenceEvidence[], codecs: OmnipackObservationCodecs, approve: (url: string) => boolean) {
28
+ let rejectedTrackingRefs = 0, rejectedServices = 0, rejectedTrackingUrls = 0;
29
+ const trackingRefs: FulfillmentObservation["trackingRefs"][number][] = [];
30
+ for (const item of refs) {
31
+ if (item.service !== null && !correlationReference(item.service, codecs)) rejectedServices++;
32
+ if (item.trackingUrl !== null && !publicTrackingUrl(item.trackingUrl, approve, codecs.trackingUrl)) rejectedTrackingUrls++;
33
+ const trackingNumber = correlationReference(item.trackingNumber, codecs);
34
+ if (!trackingNumber) { rejectedTrackingRefs++; continue; }
35
+ // Service admission precedes any derivation. A refused service cannot leak a prefix.
36
+ const serviceRef = correlationReference(item.service, codecs);
37
+ const carrierRef = item.service !== null && !serviceRef ? null : reference(codecs.normalizeCarrier
38
+ ? codecs.normalizeCarrier({ carrierRef: item.carrier, serviceRef })
39
+ : serviceRef?.split("_")[0]?.toLowerCase());
40
+ trackingRefs.push({ trackingNumber, carrierRef, serviceRef: item.service, publicUrl: item.trackingUrl });
41
+ }
42
+ return { trackingRefs, rejectedTrackingRefs, rejectedServices, rejectedTrackingUrls };
34
43
  }
35
44
  export function observation(input: {
36
45
  evidence: OmnipackWebhookEvidence; event: FulfillmentObservation["eventKind"]; status?: CanonicalLocalFulfillmentStatus;
37
46
  receivedAt: string; rawDigest: string; evidenceRef: `evidence:${string}`;
38
47
  source: "provider_webhook" | "provider_reconciliation"; fulfillmentRef?: string;
39
48
  approveTrackingUrl: (url: string) => boolean;
49
+ observationCodecs?: OmnipackObservationCodecs;
40
50
  }): FulfillmentObservation {
41
51
  const evidence = input.evidence;
42
- const externalRef = reference(evidence.providerOrderId);
43
- const providerFulfillmentRef = reference(evidence.fulfilmentNumber);
44
- const orderNumber = reference(evidence.orderNumber);
52
+ const codecs = input.observationCodecs ?? {};
53
+ const externalRef = correlationReference(evidence.providerOrderId, codecs);
54
+ const providerFulfillmentRef = correlationReference(evidence.fulfilmentNumber, codecs);
55
+ const orderNumber = correlationReference(evidence.orderNumber, codecs);
45
56
  const fulfillmentRef = reference(input.fulfillmentRef) ?? providerFulfillmentRef ?? externalRef ?? orderNumber;
46
- if (!fulfillmentRef) throw refusal("missing_correlation", "normalize");
57
+ if (!fulfillmentRef || input.source === "provider_webhook" && [evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => value !== null && !correlationReference(value, codecs))) throw refusal("missing_correlation", "normalize");
47
58
  const occurredAt = timestamp(evidence.occurredAt);
48
59
  if (evidence.occurredAt && !occurredAt) throw refusal("malformed", "normalize");
49
- const key = digest(JSON.stringify([input.event, externalRef ?? orderNumber, providerFulfillmentRef, occurredAt]));
60
+ // Compose the key with the provider's
61
+ // original bytes, including minute precision and offsets, rather than the UTC fact.
62
+ const key = buildProviderEventId(evidence);
63
+ const normalizedTracking = tracking(evidence.trackingReferences, codecs, input.approveTrackingUrl);
64
+ const rejectedTrackingRefs = normalizedTracking.rejectedTrackingRefs + (evidence.rejectedTrackingRefs ?? 0)
65
+ + (evidence.rejectedServices ?? normalizedTracking.rejectedServices);
50
66
  const status = input.status ?? localStatusFor(EVENT_TO_STATUS[input.event as OmnipackWebhookRouteEvent]);
51
67
  const fact = parseNormalizedFulfillmentFact({
52
- schemaVersion: 1, mapperVersion: "1", factId: key, providerKind: "omnipack", fulfillmentReference: fulfillmentRef,
68
+ schemaVersion: 1, mapperVersion: "1", factId: digest(key), providerKind: "omnipack", fulfillmentReference: reference(fulfillmentRef) ?? digest(fulfillmentRef),
53
69
  canonicalStatus: status, provenance: { source: input.source, authority: "provider_asserted" },
54
70
  occurredAt, receivedAt: input.receivedAt,
55
71
  rawEvent: { reference: input.evidenceRef, digestAlgorithm: "sha256", digest: input.rawDigest },
56
72
  }, { isCanonicalStatus: (value): value is CanonicalLocalFulfillmentStatus =>
57
73
  ["provider_received", "picking", "packed", "in_transit", "delivered", "exception", "cancelled"].includes(value) });
58
- return { fact, eventKind: input.event, eventKey: key, firstReceivedAt: input.receivedAt,
59
- correlation: { orderRef: null, fulfillmentRef: reference(input.fulfillmentRef), externalRef, orderNumber, providerFulfillmentRef, parcelRef: null },
60
- trackingRefs: tracking(evidence.trackingReferences, input.approveTrackingUrl) };
74
+ return projectObservation({ fact, eventKind: input.event, eventKey: key, firstReceivedAt: input.receivedAt,
75
+ correlation: { orderRef: null, fulfillmentRef: reference(input.fulfillmentRef), externalRef: evidence.providerOrderId, orderNumber: evidence.orderNumber, providerFulfillmentRef: evidence.fulfilmentNumber, parcelRef: null },
76
+ trackingRefs: normalizedTracking.trackingRefs, diagnostics: { admission: {
77
+ rejectedTrackingRefs, rejectedTrackingUrls: evidence.rejectedTrackingUrls ?? normalizedTracking.rejectedTrackingUrls,
78
+ } } }, input.approveTrackingUrl, codecs);
61
79
  }
@@ -0,0 +1,56 @@
1
+ import type { FulfillmentObservationPagePort, FulfillmentReconciliationObservation } from "../contracts/index.js";
2
+ import { operationError } from "../dispatchProjection.js";
3
+ import { projectPollEvidence, projectObservation } from "../observationProjection.js";
4
+ import { normalizeReconciliationTimestamp } from "../providerReconciliationTimestamp.js";
5
+ import type { OmnipackClient } from "./client.js";
6
+ import { OmnipackProviderError } from "./client.js";
7
+ import type { OmnipackFulfilmentEvidence } from "./mappers.js";
8
+ import { digest, observation, reference, refusal, timestamp, type OmnipackObservationCodecs } from "./observation.js";
9
+ import { localStatusFor, normalizeStatus } from "./omnipackStatusVocabulary.js";
10
+
11
+ type Config = {
12
+ observationCodecs?: OmnipackObservationCodecs;
13
+ clock(): string; mintEvidenceReference(): `evidence:${string}`; approveTrackingUrl(url: string): boolean;
14
+ };
15
+ export function reconciliationObservation(row: OmnipackFulfilmentEvidence, config: Config, fulfillmentRef?: string): Extract<FulfillmentReconciliationObservation, { kind: "found" | "unmapped" }> {
16
+ const providerStatus = { primary: row.status, subStatus: row.subStatus };
17
+ // Original status and occurrence bytes remain part of identity, even for aliases
18
+ // mapping to the same canonical event. Received time never changes that identity.
19
+ const eventKey = digest(JSON.stringify([row.providerOrderId, row.fulfilmentNumber, row.orderNumber, row.status, row.subStatus, row.updatedAt]));
20
+ const correlation = { orderRef: null, fulfillmentRef: fulfillmentRef ?? null, externalRef: reference(row.providerOrderId),
21
+ orderNumber: reference(row.orderNumber), providerFulfillmentRef: reference(row.fulfilmentNumber), parcelRef: null };
22
+ const normalized = normalizeStatus(row.status);
23
+ if (!normalized) return { kind: "unmapped", externalRef: correlation.externalRef, evidence: projectPollEvidence({ eventKey, correlation: { ...correlation, externalRef: row.providerOrderId, orderNumber: row.orderNumber, providerFulfillmentRef: row.fulfilmentNumber }, providerStatus }) };
24
+ const status = localStatusFor(normalized) as "provider_received" | "picking" | "packed" | "in_transit" | "delivered" | "exception";
25
+ const event = ({ provider_received: "shipment.accepted", picking: "order.processing_started", packed: "order.picked", in_transit: "order.shipped", delivered: "order.delivered", exception: "parcel.exception" } as const)[status];
26
+ const occurredAt = normalizeReconciliationTimestamp(row.updatedAt);
27
+ const evidence = { provider: "omnipack" as const, event, providerOrderId: row.providerOrderId, orderNumber: row.orderNumber,
28
+ fulfilmentNumber: row.fulfilmentNumber, occurredAt, trackingNumbers: row.trackingNumbers,
29
+ trackingReferences: row.trackingReferences, shippingMethods: [] };
30
+ const mapped = observation({ evidence, event, status, fulfillmentRef, receivedAt: config.clock(),
31
+ rawDigest: digest(JSON.stringify({ ...evidence, providerStatus })), evidenceRef: config.mintEvidenceReference(), source: "provider_reconciliation", approveTrackingUrl: config.approveTrackingUrl, observationCodecs: config.observationCodecs });
32
+ return { kind: "found", externalRef: row.providerOrderId ?? "", createdAt: timestamp(row.createdAt), updatedAt: timestamp(occurredAt),
33
+ observation: projectObservation({ ...mapped, eventKey, fact: { ...mapped.fact, factId: eventKey }, providerStatus }, config.approveTrackingUrl, config.observationCodecs) };
34
+ }
35
+
36
+ export function createReconciliationPagePort(client: OmnipackClient, config: Config, ready: () => ReturnType<typeof refusal> | null): FulfillmentObservationPagePort {
37
+ return {
38
+ async readPage(input) {
39
+ const inactive = ready();
40
+ if (inactive) return { kind: "failed", refusal: inactive };
41
+ if (!Number.isSafeInteger(input.page) || input.page < 0 || !Number.isSafeInteger(input.size) || input.size <= 0)
42
+ return { kind: "failed", refusal: refusal("malformed", "readback") };
43
+ try {
44
+ const rows = await client.getFulfilments(input);
45
+ return { kind: "page", rows: rows.map(row => {
46
+ try { return reconciliationObservation(row, config); }
47
+ catch { return { kind: "unknown" as const, refusal: refusal("malformed", "normalize") }; }
48
+ }) };
49
+ } catch (error) {
50
+ if (error instanceof OmnipackProviderError && error.status === 400 && error.code === "ERR_PAGE_MUST_BE_LESS_THAN_TOTAL_PAGES") return { kind: "page_exhausted" };
51
+ return { kind: "failed", refusal: refusal("io_failure", "readback", error instanceof OmnipackProviderError && error.retryable),
52
+ error: operationError(error instanceof OmnipackProviderError ? error : { code: "provider_read_failed", status: null, retryable: false, mayHaveSucceeded: false }) };
53
+ }
54
+ },
55
+ };
56
+ }
@@ -21,7 +21,7 @@ export function createPostgresFulfillmentDispatchStore(db: PgExecutor, host: {
21
21
  readCandidate: ref => host.readCandidate(ref),
22
22
  prepare: ref => host.prepare(ref),
23
23
  async read(key) {
24
- const result = await db.query('SELECT public.fulfillment_dispatch_read(ref) AS response FROM public.fulfillment_dispatch_refs ref WHERE request_key=$1', [key]);
24
+ const result = await db.query("SELECT public.fulfillment_dispatch_read(ref) || jsonb_build_object('acceptance',ref.acceptance) AS response FROM public.fulfillment_dispatch_refs ref WHERE request_key=$1", [key]);
25
25
  return result.rows[0]?.response as DispatchRecord | undefined ?? null;
26
26
  },
27
27
  claim: input => nativeCall(db, 'fulfillment_dispatch_claim', [input.requestKey, input.fulfillmentRef, input.orderRef, input.parcelRef, input.mode, JSON.stringify(dispatchEvidence(input.evidence))]),
@@ -1,3 +1,5 @@
1
+ import type { FulfillmentObservationCodecs } from "./contracts/index.js";
2
+ import { safeDiagnosticCode } from "./dispatchProjection.js";
1
3
  import { nativeCall } from './postgresDispatchStore.js';
2
4
  import { projectObservation } from './observationProjection.js';
3
5
  import type { PgExecutor as PgQueryExecutor } from './postgresDispatchStore.js';
@@ -64,6 +66,8 @@ const RAISE_EXCEPTION_SQL = `
64
66
 
65
67
  export interface PostgresFulfillmentShipmentSpineOptions {
66
68
  portKey: string;
69
+ approveTrackingUrl?(url: string): boolean;
70
+ observationCodecs?: FulfillmentObservationCodecs;
67
71
  evidenceChannel?: "poll" | "push";
68
72
  requiredProviderType?: "simulator";
69
73
  }
@@ -115,7 +119,7 @@ export function createPostgresFulfillmentShipmentSpinePort(
115
119
  };
116
120
 
117
121
  return {
118
- localPersistence: localPersistence(executor),
122
+ localPersistence: localPersistence(executor, options.approveTrackingUrl, options.observationCodecs),
119
123
  createShipment(request) {
120
124
  return callCreate(executor, portKey, [
121
125
  request.idempotencyKey,
@@ -296,7 +300,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {
296
300
  }
297
301
 
298
302
  /** G17 native local persistence, composed on the same injected executor. */
299
- function localPersistence(executor: PgQueryExecutor) {
303
+ function localPersistence(executor: PgQueryExecutor, approveTrackingUrl?: (url: string) => boolean, observationCodecs?: FulfillmentObservationCodecs) {
300
304
  return {
301
305
  async readLocalFulfillmentState(ref: string) {
302
306
  const result = await executor.query(`SELECT shipment.status,shipment.handed_over_at,
@@ -305,15 +309,28 @@ function localPersistence(executor: PgQueryExecutor) {
305
309
  const row = result.rows[0];
306
310
  return { status: typeof row?.status === 'string' ? row.status : null, latestStatusOccurredAt: date(row?.latest_status_occurred_at), handedOverAt: date(row?.handed_over_at) };
307
311
  },
312
+ async readLocalFulfillmentStatus(ref: string) {
313
+ const result = await executor.query('SELECT status FROM public.fulfillment_shipments WHERE id=$1::uuid', [ref]);
314
+ return typeof result.rows[0]?.status === 'string' ? result.rows[0].status : null;
315
+ },
316
+ async latestStatusOccurredAt(ref: string) {
317
+ const result = await executor.query("SELECT max((observation#>>'{fact,occurredAt}')::timestamptz) AS latest_status_occurred_at FROM public.fulfillment_observations WHERE shipment_id=$1::uuid AND status_recorded", [ref]);
318
+ return date(result.rows[0]?.latest_status_occurred_at);
319
+ },
320
+ async readHandedOverAt(ref: string) {
321
+ const result = await executor.query('SELECT handed_over_at FROM public.fulfillment_shipments WHERE id=$1::uuid', [ref]);
322
+ return date(result.rows[0]?.handed_over_at);
323
+ },
308
324
  async recordObservation(observation: import('./contracts/index.js').FulfillmentObservation) {
309
- const safe = projectObservation(observation);
325
+ const safe = projectObservation(observation, approveTrackingUrl, observationCodecs);
310
326
  return nativeCall<{ replayed: boolean }>(executor,'fulfillment_record_observation',[safe.eventKey,safe.correlation.fulfillmentRef,JSON.stringify(safe),safe.firstReceivedAt]);
311
327
  },
312
328
  async recordStatusEvidence(ref: { fulfillmentRef: string }, observation: import('./contracts/index.js').FulfillmentObservation) {
313
329
  return nativeCall<{ replayed: boolean }>(executor,'fulfillment_record_status_evidence',[observation.eventKey,ref.fulfillmentRef]);
314
330
  },
315
- async markProcessed(eventKey: string, outcome: { status: 'processed' | 'ignored'; refusal?: import('./contracts/index.js').FulfillmentRefusal }) {
331
+ async markProcessed(eventKey: string, outcome: { status: 'processed' | 'ignored'; refusal?: import('./contracts/index.js').FulfillmentRefusal; supersededCause?: string }) {
316
332
  await nativeCall(executor,'fulfillment_mark_observation_processed',[eventKey,JSON.stringify({ status: outcome.status,
333
+ ...(outcome.supersededCause ? { supersededCause: safeDiagnosticCode(outcome.supersededCause) ?? 'fulfillment_state_conflict' } : {}),
317
334
  ...(outcome.refusal ? { refusal: { reason: outcome.refusal.reason,phase: outcome.refusal.phase,retryable: outcome.refusal.retryable } } : {}) })]);
318
335
  },
319
336
  };
@@ -17,14 +17,14 @@ export function createPostgresFulfillmentStockSyncPort(db: PgExecutor, options:
17
17
  const row = result.rows[0];
18
18
  if (!row) return null;
19
19
  const cursor = row.cursor as Partial<SyncCursor>;
20
- return { status: row.status as SyncCursor['status'], lastStockSyncedAt: timestamp(row.last_stock_synced_at), lastMovementOccurredAt: timestamp(row.last_movement_occurred_at),
20
+ return { ...cursor, status: row.status as SyncCursor['status'], lastStockSyncedAt: timestamp(row.last_stock_synced_at), lastMovementOccurredAt: timestamp(row.last_movement_occurred_at),
21
21
  continuation: cursor.continuation ?? { nextRef: null, startLocalDate: null, endLocalDate: null }, coverageGapSkus: cursor.coverageGapSkus ?? [], refusal: cursor.refusal ?? null };
22
22
  },
23
23
  async recordCursor(v) {
24
24
  await db.query(`INSERT INTO public.fulfillment_stock_sync_cursors(source_key,status,last_stock_synced_at,last_movement_occurred_at,cursor,error_code,updated_at)
25
25
  VALUES($1,$2,$3,$4,$5::jsonb,$6,now()) ON CONFLICT(source_key) DO UPDATE SET status=EXCLUDED.status,
26
26
  last_stock_synced_at=EXCLUDED.last_stock_synced_at,last_movement_occurred_at=EXCLUDED.last_movement_occurred_at,cursor=EXCLUDED.cursor,error_code=EXCLUDED.error_code,updated_at=now()`,
27
- [source,v.status,v.lastStockSyncedAt,v.lastMovementOccurredAt,JSON.stringify({ continuation: { nextRef: v.continuation.nextRef, startLocalDate: v.continuation.startLocalDate, endLocalDate: v.continuation.endLocalDate }, coverageGapSkus: [...(v.coverageGapSkus ?? [])], refusal: v.refusal }),v.refusal?.reason ?? null]);
27
+ [source,v.status,v.lastStockSyncedAt,v.lastMovementOccurredAt,JSON.stringify({ syncRunId: v.syncRunId, counts: v.counts, items: v.items, alerts: v.alerts, staleSync: v.staleSync, error: v.error, continuation: { nextRef: v.continuation.nextRef, startLocalDate: v.continuation.startLocalDate, endLocalDate: v.continuation.endLocalDate }, coverageGapSkus: [...(v.coverageGapSkus ?? [])], refusal: v.refusal }),v.error?.reason ?? v.refusal?.reason ?? null]);
28
28
  },
29
29
  async readSkuInventoryClasses(skus) {
30
30
  if (!skus.length) return new Map();
@@ -52,7 +52,10 @@ export function createPostgresFulfillmentStockSyncPort(db: PgExecutor, options:
52
52
  const movement = { providerRef, syncRunId, collectedAt, sourceRevision, revisionSource, skuRef, quantityKind, quantity, occurredAt, operationType, documentRef, lotRef, expirationDate };
53
53
  return nativeCall(db,'fulfillment_record_stock_movement',[source,v.requestKey,fingerprint({ requestKey: v.requestKey, movement }),JSON.stringify(movement)]);
54
54
  },
55
- recordLowStockEvidence: v => nativeCall(db,'fulfillment_record_stock_evidence',[source,v.requestKey,fingerprint(v),v.skuRef,v.thresholdKind,v.severity,v.providerForSaleQuantity,v.localAvailableQuantity]),
55
+ recordLowStockEvidence: v => nativeCall(db,'fulfillment_record_stock_evidence',[source,v.requestKey,
56
+ // Quantities, severity and run evidence are observations of the same alert identity.
57
+ fingerprint({ requestKey: v.requestKey, skuRef: v.skuRef, thresholdKind: v.thresholdKind }),
58
+ v.skuRef,v.thresholdKind,v.severity,v.providerForSaleQuantity,v.localAvailableQuantity]),
56
59
  resolveLowStockEvidence: v => nativeCall(db,'fulfillment_resolve_stock_evidence',[source,v.skuRef,v.activeThresholdKinds,v.syncRunId]),
57
60
  };
58
61
  }
@@ -1,5 +1,5 @@
1
1
  import type { DispatchRecord, FulfillmentDispatchResult, FulfillmentStorePort } from "./contracts/index.js";
2
- import { dispatchEvidence } from "./dispatchProjection.js";
2
+ import { durableStrings, dispatchEvidence, dispatchResult, localFailureReason } from "./dispatchProjection.js";
3
3
 
4
4
  export function acknowledgeAcceptedDispatch(input: {
5
5
  store: FulfillmentStorePort;
@@ -7,22 +7,28 @@ export function acknowledgeAcceptedDispatch(input: {
7
7
  keys: { providerAttemptKey: string; labelKey: string };
8
8
  }) {
9
9
  return input.store.acknowledge({ dispatchRef: input.record.dispatchRef, acceptance: {
10
+ ...(input.record.acceptance ? dispatchResult(input.record.acceptance) : {}),
11
+ ...(input.record.status === "uncertain" ? { recovery: { source: "persisted_acceptance" as const, proof: "persisted_external_ref" as const, noProviderPost: true as const } } : {}),
10
12
  kind: "accepted", externalRef: input.record.externalRef, evidence: dispatchEvidence(input.record.evidence), ...input.keys,
11
13
  } });
12
14
  }
13
15
 
14
16
  export async function recoverDispatchAcceptanceWrite(input: {
15
17
  store: FulfillmentStorePort; requestKey: string; dispatchRef: string;
16
- acceptance: Extract<FulfillmentDispatchResult, { kind: "accepted" }>;
18
+ acceptance: Extract<FulfillmentDispatchResult, { kind: "accepted" }>; error: unknown;
17
19
  }): Promise<{ committed: boolean; readBacks: number }> {
18
20
  let record = await input.store.read(input.requestKey).catch(() => null);
19
21
  let readBacks = record ? 1 : 0;
20
22
  const committed = () => record?.status === "created" && record.externalRef === input.acceptance.externalRef;
21
23
  if (committed()) return { committed: true, readBacks };
24
+ const privacy = durableStrings();
25
+ const cause = localFailureReason("ack", input.error, privacy);
22
26
  try {
23
27
  // Accepted proof survives a failed atomic ACK; finalization records uncertainty,
24
28
  // and the next worker repairs the ACK locally without another provider call.
25
- record = await input.store.finalize({ dispatchRef: input.dispatchRef, result: input.acceptance });
29
+ record = await input.store.finalize({ dispatchRef: input.dispatchRef, result: { ...input.acceptance, error: {
30
+ code: "dispatch_acceptance_write_failed", cause, ...(privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}), status: null, retryable: false, mayHaveSucceeded: true,
31
+ } } });
26
32
  readBacks += 1;
27
33
  } catch {
28
34
  // Submitting remains fenced; the stale sweep moves it to uncertain.
@@ -1,5 +1,5 @@
1
- import type { DispatchRecord, FulfillmentDispatchPort, FulfillmentDispatchRequest, FulfillmentStorePort } from "./contracts/index.js";
2
- import { acceptedProviderProof, dispatchEvidence, dispatchResult } from "./dispatchProjection.js";
1
+ import type { DispatchRecord, FulfillmentDispatchPort, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentStorePort } from "./contracts/index.js";
2
+ import { durableStrings, acceptedProviderProof, dispatchEvidence, dispatchResult, localFailureReason, shadowDispatchEvidence } from "./dispatchProjection.js";
3
3
  import { acknowledgeAcceptedDispatch, recoverDispatchAcceptanceWrite } from "./providerDispatchAcceptance.js";
4
4
  import { unknownDispatchFailure } from "./providerDispatchError.js";
5
5
 
@@ -16,16 +16,20 @@ export async function runFulfillmentDispatchWorker(input: {
16
16
  config: { mode: DispatchRecord["mode"]; batchLimit: number };
17
17
  now?: () => Date;
18
18
  }) {
19
+ const privacy = durableStrings();
20
+ const failure = (stage: string, error: unknown) => localFailureReason(stage, error, privacy);
21
+ const diagnostics = () => privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {};
19
22
  const result = { ok: true, skipped: false, checked: 0, updated: 0, failures: 0,
20
23
  mode: input.config.mode, replayed: 0, providerCalls: 0, readBacks: 0, reason: undefined as string | undefined };
21
24
  if (input.config.mode !== "shadow") {
22
25
  try { await input.store.markStaleSubmissionsUncertain(new Date((input.now?.() ?? new Date()).getTime() - 5 * 60_000).toISOString()); }
23
- catch { return { ...result, ok: false, failures: 1, reason: "dispatch_stale_submission_sweep_failed" }; }
26
+ catch (error) { return { ...result, ok: false, failures: 1, reason: failure("dispatch_stale_submission_sweep_failed", error), ...diagnostics() }; }
24
27
  }
25
28
  const candidates = await input.listCandidates(input.config.batchLimit);
26
29
  result.checked = candidates.length;
27
30
  candidateLoop: for (const candidate of candidates) {
28
31
  const requestKey = input.keys.request(candidate);
32
+ let stage = "dispatch_read_failed";
29
33
  try {
30
34
  let readBack = await input.store.read(requestKey);
31
35
  if (readBack) result.readBacks += 1;
@@ -35,26 +39,35 @@ export async function runFulfillmentDispatchWorker(input: {
35
39
  result.updated += ack.replayed ? 0 : 1;
36
40
  continue;
37
41
  }
42
+ stage = "dispatch_prepare_failed";
38
43
  const preparation = await input.store.prepare(candidate.fulfillmentRef);
39
44
  if (preparation.disposition === "withheld") continue;
45
+ stage = "dispatch_candidate_read_failed";
40
46
  const preparedCandidate = await input.store.readCandidate(candidate.fulfillmentRef);
41
47
  if (!preparedCandidate) throw new Error("dispatch_prepared_candidate_missing");
42
48
  if (input.config.mode !== "shadow" && !input.provider) {
43
49
  result.ok = false; result.failures += 1; result.reason ??= "provider_not_configured"; continue;
44
50
  }
45
- // Shadow needs command preparation too, but never calls dispatch.
46
- if (!input.provider) throw new Error("provider_command_preparation_not_configured");
47
51
  let mappedCandidate = preparedCandidate;
48
52
  let dispatchRef: DispatchRecord;
49
53
  let evidence: DispatchRecord["evidence"];
54
+ let providerEvidence: DispatchRecord["evidence"];
50
55
  let mappedAttemptCount = 0;
51
56
  for (let remapCount = 0; ; remapCount += 1) {
52
57
  const request = { ...mappedCandidate, requestKey };
53
- const prepared = await input.provider.prepare(request);
58
+ stage = "dispatch_command_prepare_failed";
59
+ const prepared = input.config.mode === "shadow"
60
+ ? { kind: "prepared" as const, evidence: shadowDispatchEvidence(request) }
61
+ : await input.provider!.prepare(request);
54
62
  if (prepared.kind === "refused") {
55
- result.ok = false; result.failures += 1; result.reason ??= prepared.refusal.reason; continue candidateLoop;
63
+ result.ok = false; result.failures += 1; result.reason ??= privacy.token(prepared.refusal.reason) ?? "redacted"; continue candidateLoop;
56
64
  }
57
- evidence = dispatchEvidence(prepared.evidence);
65
+ providerEvidence = dispatchEvidence(prepared.evidence);
66
+ evidence = { ...providerEvidence, mapping: { source: "dispatch_worker", mode: input.config.mode, shadowMode: input.config.mode === "shadow" } };
67
+ // Native claims count any JSON change as a remap; attempts compare
68
+ // the whole JSON. Enrichment must not rewrite an existing identity.
69
+ if (readBack && sameCommandIdentity(readBack.evidence, evidence)) evidence = dispatchEvidence(readBack.evidence);
70
+ stage = "dispatch_claim_failed";
58
71
  const claim = await input.store.claim({ requestKey, fulfillmentRef: mappedCandidate.fulfillmentRef,
59
72
  orderRef: mappedCandidate.orderRef, parcelRef: mappedCandidate.parcelRef, mode: input.config.mode, evidence });
60
73
  if (claim.kind === "refused") {
@@ -63,14 +76,16 @@ export async function runFulfillmentDispatchWorker(input: {
63
76
  if (!refreshed) throw new Error("dispatch_contact_remap_candidate_missing");
64
77
  mappedCandidate = refreshed; continue;
65
78
  }
66
- result.ok = false; result.failures += 1; result.reason ??= claim.refusal.reason; continue candidateLoop;
79
+ result.ok = false; result.failures += 1; result.reason ??= privacy.token(claim.refusal.reason) ?? "redacted"; continue candidateLoop;
67
80
  }
68
81
  dispatchRef = claim.record;
69
82
  if (dispatchRef.replayed) result.replayed += 1;
70
83
  // Re-claim may refresh a correctable draft atomically; readback is authoritative.
84
+ stage = "dispatch_readback_failed";
71
85
  readBack = await input.store.read(requestKey);
72
86
  if (readBack) result.readBacks += 1;
73
87
  if (!readBack || readBack.fulfillmentRef !== mappedCandidate.fulfillmentRef) throw new Error("dispatch_readback_failed");
88
+ stage = "dispatch_attempt_write_failed";
74
89
  await input.store.recordProviderAttempt(input.keys.mappedAttempt(requestKey, mappedAttemptCount),
75
90
  mappedCandidate.fulfillmentRef, evidence, input.config.mode, "recorded");
76
91
  mappedAttemptCount += 1;
@@ -80,6 +95,7 @@ export async function runFulfillmentDispatchWorker(input: {
80
95
  if (readBack.status !== "draft") {
81
96
  result.ok = false; result.failures += 1; result.reason ??= `dispatch_not_safe_to_submit:${readBack.status}`; continue candidateLoop;
82
97
  }
98
+ stage = "dispatch_begin_failed";
83
99
  const submission = await input.store.begin({ dispatchRef: dispatchRef.dispatchRef, commandFingerprint: evidence.commandFingerprint });
84
100
  if (submission.kind === "refused") {
85
101
  if (remapCount === 0 && submission.refusal.reason === "contact_stale") {
@@ -87,9 +103,10 @@ export async function runFulfillmentDispatchWorker(input: {
87
103
  if (!refreshed) throw new Error("dispatch_contact_remap_candidate_missing");
88
104
  mappedCandidate = refreshed; continue;
89
105
  }
90
- result.ok = false; result.failures += 1; result.reason ??= submission.refusal.reason; continue candidateLoop;
106
+ result.ok = false; result.failures += 1; result.reason ??= privacy.token(submission.refusal.reason) ?? "redacted"; continue candidateLoop;
91
107
  }
92
108
  if (!submission.begun) {
109
+ stage = "dispatch_concurrent_read_failed";
93
110
  const concurrent = await input.store.read(requestKey);
94
111
  if (concurrent) result.readBacks += 1;
95
112
  if (acceptedProviderProof(concurrent)) {
@@ -103,29 +120,40 @@ export async function runFulfillmentDispatchWorker(input: {
103
120
  break;
104
121
  }
105
122
  result.providerCalls += 1;
106
- let response;
107
- try { response = dispatchResult(await input.provider.dispatch({ ...mappedCandidate, requestKey }, evidence)); }
123
+ let response: FulfillmentDispatchResult;
124
+ try { response = dispatchResult(await input.provider!.dispatch({ ...mappedCandidate, requestKey }, providerEvidence)); }
108
125
  catch { response = unknownDispatchFailure(); }
126
+ if (response.kind === "accepted" && JSON.stringify(response.evidence) !== JSON.stringify(providerEvidence)) {
127
+ response = { kind: "unknown", effectMayHaveSucceeded: true, refusal: { reason: "malformed", phase: "acknowledge", retryable: false },
128
+ error: { code: "dispatch_acceptance_evidence_mismatch", status: null, retryable: false, mayHaveSucceeded: true } };
129
+ }
109
130
  if (response.kind !== "accepted") {
131
+ stage = "dispatch_finalize_failed";
110
132
  await input.store.finalize({ dispatchRef: dispatchRef.dispatchRef, result: response });
111
- result.ok = false; result.failures += 1; result.reason ??= response.refusal.reason; continue;
133
+ result.ok = false; result.failures += 1; result.reason ??= response.error?.code ?? response.refusal.reason; continue;
112
134
  }
113
135
  // All ACK paths use the original host keys, ignoring adapter key fields.
114
- const acceptance = { ...response, ...input.keys.acceptance(mappedCandidate.fulfillmentRef) };
136
+ const acceptance = { ...response, evidence, ...input.keys.acceptance(mappedCandidate.fulfillmentRef) };
115
137
  try {
116
138
  const ack = await input.store.acknowledge({ dispatchRef: dispatchRef.dispatchRef, acceptance });
117
139
  if (ack.replayed) result.replayed += 1;
118
- } catch {
119
- const recovery = await recoverDispatchAcceptanceWrite({ store: input.store, requestKey, dispatchRef: dispatchRef.dispatchRef, acceptance });
140
+ } catch (error) {
141
+ const recovery = await recoverDispatchAcceptanceWrite({ store: input.store, requestKey, dispatchRef: dispatchRef.dispatchRef, acceptance, error });
120
142
  result.readBacks += recovery.readBacks;
121
143
  if (recovery.committed) { result.updated += 1; continue; }
122
144
  result.ok = false; result.failures += 1; result.reason ??= "dispatch_proof_write_failed_after_provider_acceptance"; break;
123
145
  }
124
146
  result.updated += 1;
125
- } catch {
126
- // Exception messages may contain raw provider bodies or credentials.
127
- result.ok = false; result.failures += 1; result.reason ??= "dispatch_local_operation_failed";
147
+ } catch (error) {
148
+ result.ok = false; result.failures += 1; result.reason ??= failure(stage, error);
128
149
  }
129
150
  }
130
- return result;
151
+ return { ...result, ...diagnostics() };
152
+ }
153
+
154
+ function sameCommandIdentity(a: DispatchRecord["evidence"], b: DispatchRecord["evidence"]): boolean {
155
+ return a.commandFingerprint === b.commandFingerprint && a.providerRef === b.providerRef
156
+ && a.carrierRef === b.carrierRef && a.serviceRef === b.serviceRef
157
+ && a.contactRevision.schemaVersion === b.contactRevision.schemaVersion
158
+ && a.contactRevision.source === b.contactRevision.source && a.contactRevision.revision === b.contactRevision.revision;
131
159
  }