@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
@@ -19,13 +19,24 @@ export interface SyncCursor {
19
19
  /** G15: successful sellable coverage gaps; legacy absent values read as []. */
20
20
  coverageGapSkus?: readonly string[];
21
21
  refusal: FulfillmentRefusal | null;
22
+ /** Per-entry bounded diagnostics and run evidence in the existing cursor JSON, never raw errors. */
23
+ syncRunId?: string;
24
+ counts?: { checked: number; updated: number; movements: number; rejected: number; failures: number; providerCalls: number; readBacks: number };
25
+ items?: readonly { kind?: "stock" | "movement"; itemIndex: number; skuRef: string | null; status: "recorded" | "failed" | "rejected"; reason?: string;
26
+ activeReserved?: number; sellableNow?: number | null; coverageGapQuantity?: number }[];
27
+ alerts?: readonly { skuRef: string; thresholdKind: Exclude<Threshold, "forecast">; severity: "info" | "warning" | "critical";
28
+ providerForSaleQuantity: number; localAvailableQuantity: number; detectedAt: string;
29
+ activeReserved?: number; coverageGapQuantity?: number; gapHours?: number; thresholdHours?: number }[];
30
+ staleSync?: { gapHours: number; thresholdHours: number; detectedAt: string };
31
+ error?: { reason: string; providerError?: { operation: "get_stock" | "get_stock_movements"; status: number | null; code: string; retryable: boolean } } | null;
22
32
  }
23
33
  export interface LocalStock { skuRef: string; onHand: number; reserved: number; unavailable: number; safetyStock: number }
24
34
  type Threshold = "safety_stock" | "reservation_coverage" | "forecast" | "stale_sync";
25
35
  type Conflict = "unknown_sku" | "ean_conflict" | "kind_quantity_conflict";
26
36
  export interface FulfillmentStockSyncProvider {
27
- getStock(): Promise<StockSnapshot[]>;
28
- getStockMovements(input?: { startLocalDate?: string; endLocalDate?: string; size?: number }): Promise<StockMovement[]>;
37
+ /** Rejected rows carry only their input index and a closed reason, never an unsafe reference. */
38
+ getStock(): Promise<(StockSnapshot | { kind: "rejected"; itemIndex: number; reason: "invalid_sku_ref" })[]>;
39
+ getStockMovements(input?: { startLocalDate?: string; endLocalDate?: string; size?: number }): Promise<(StockMovement | { kind: "rejected"; itemIndex: number; reason: "invalid_sku_ref" })[]>;
29
40
  }
30
41
  export interface FulfillmentStockSyncPort {
31
42
  readCursor(): Promise<SyncCursor | null>;
@@ -1,18 +1,105 @@
1
- import type { DispatchEvidence, DispatchRecord, FulfillmentDispatchResult } from "./contracts/index.js";
1
+ import { createHash } from "node:crypto";
2
+ import type { DispatchEvidence, DispatchRecord, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentOperationError } from "./contracts/index.js";
2
3
 
4
+ /** Machine identities only: arbitrary exception prose, URLs and credentials are omitted. */
5
+ export function safeDiagnosticCode(value: unknown): string | null {
6
+ return reference(value) && typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_]{0,95}$/u.test(value) ? value : null;
7
+ }
8
+ export function localFailureReason(stage: string, error: unknown, privacy = durableStrings()): string {
9
+ if (!error || typeof error !== "object") return stage;
10
+ const fields = error as { code?: unknown; causeCode?: unknown; causeMessage?: unknown; message?: unknown };
11
+ const diagnostic = (value: unknown) => typeof value === "string" ? privacy.token(value, safeDiagnosticCode) : null;
12
+ const ownCode = diagnostic(fields.code), causeCode = diagnostic(fields.causeCode), causeMessage = diagnostic(fields.causeMessage);
13
+ const messagePattern = /^([A-Za-z][A-Za-z0-9_]*)(?::([A-Z0-9]{5}))?$/u;
14
+ const safeMessage = typeof fields.message === "string" ? privacy.token(fields.message, value => messagePattern.test(value) ? value : null) : null;
15
+ const message = safeMessage?.match(messagePattern);
16
+ const cause = causeMessage ?? (message && (message[2] || /_(?:failed|down|missing|invalid|conflict|not_found)$/u.test(message[1]!)) ? safeDiagnosticCode(message[1]) : null);
17
+ const code = causeCode ?? ownCode ?? message?.[2];
18
+ return [stage, cause, code].filter(Boolean).join(":");
19
+ }
20
+ export function operationError(error: Omit<FulfillmentOperationError, "cause"> & { cause?: unknown }): FulfillmentOperationError {
21
+ const privacy = durableStrings(error.redactedFieldCount);
22
+ const code = privacy.token(error.code, safeDiagnosticCode) ?? "operation_failed";
23
+ const cause = typeof error.cause === "string" ? privacy.token(error.cause,
24
+ value => /^ack(?::[A-Za-z0-9_]{1,96}){0,2}$/u.test(value) ? value.slice(0,180) : null) : null;
25
+ return { code, status: Number.isInteger(error.status) && error.status! >= 100 && error.status! <= 599 ? error.status : null,
26
+ retryable: error.retryable === true, mayHaveSucceeded: error.mayHaveSucceeded === true,
27
+ ...(typeof error.submissionAttempted === "boolean" ? { submissionAttempted: error.submissionAttempted } : {}),
28
+ ...(typeof error.responseBodyRead === "boolean" ? { responseBodyRead: error.responseBodyRead } : {}),
29
+ ...(cause ? { cause } : {}), ...(privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}) };
30
+ }
31
+ // The fulfillment opaque-ID rule; never accept free-form metadata as a reference.
32
+ export const reference = (value: unknown): string | null => typeof value === "string"
33
+ && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value) ? value : null;
34
+ /** Opaque by default; an explicit allowlist can admit wider incumbent references. */
35
+ export function admittedReference(value: string | null | undefined, approve?: (value: string) => boolean): string | null {
36
+ if (typeof value !== "string" || /[\p{Cc}\p{Cf}]/u.test(value)) return null;
37
+ const admitted = approve ? approve(value) : reference(value) !== null;
38
+ const text = value.trim();
39
+ return admitted && text && text.length <= 256 && !/[@]|[a-z][a-z0-9+.-]*:\/\//iu.test(text) ? text : null;
40
+ }
41
+ const isoDate = (value: string | null | undefined): string | null => typeof value === "string"
42
+ && /^\d{4}-\d\d-\d\d$/u.test(value) && Number.isFinite(Date.parse(value))
43
+ && new Date(value).toISOString().slice(0, 10) === value ? value : null;
44
+ /** One counted sanitizer for durable projections; codecs are explicit host policy. */
45
+ export function durableStrings(priorCount = 0) {
46
+ let count = Number.isSafeInteger(priorCount) && priorCount > 0 ? priorCount : 0;
47
+ return {
48
+ token(value: string | null | undefined, codec: (value: string) => string | null = reference) {
49
+ if (value == null) return null;
50
+ const safe = codec(value);
51
+ if (safe === null) count += 1;
52
+ return safe;
53
+ },
54
+ get redactedFieldCount() { return count; },
55
+ };
56
+ }
3
57
  /** Copy only the durable allowlist, including when an adapter object has extra runtime properties. */
4
58
  export function dispatchEvidence(evidence: DispatchEvidence): DispatchEvidence {
59
+ const privacy = durableStrings(evidence.redactedFieldCount ?? evidence.transcript?.redactedFieldCount);
60
+ const sanitize = privacy.token;
61
+ const commandFingerprint = evidence.commandFingerprint;
62
+ const source = evidence.contactRevision.source;
63
+ const providerRef = sanitize(evidence.providerRef) ?? "redacted", carrierRef = sanitize(evidence.carrierRef), serviceRef = sanitize(evidence.serviceRef);
64
+ const mode = evidence.mapping ? (sanitize(evidence.mapping.mode) ?? "shadow") as NonNullable<DispatchEvidence["mapping"]>["mode"] : null;
65
+ const transcript = evidence.transcript ? {
66
+ itemCount: evidence.transcript.items.length,
67
+ items: evidence.transcript.items.map(item => ({ skuRef: sanitize(item.skuRef) ?? "redacted", quantity: Number.isSafeInteger(item.quantity) && item.quantity > 0 ? item.quantity : 0,
68
+ lotRef: sanitize(item.lotRef), expirationDate: sanitize(item.expirationDate, isoDate) })),
69
+ deliveryKind: evidence.transcript.deliveryKind === "parcel-locker" ? "parcel-locker" as const : "courier" as const,
70
+ pickupPointRef: sanitize(evidence.transcript.pickupPointRef),
71
+ ...(privacy.redactedFieldCount > 0 ? { redactedFieldCount: privacy.redactedFieldCount } : {}),
72
+ } : null;
5
73
  return {
6
- commandFingerprint: evidence.commandFingerprint,
7
- contactRevision: { schemaVersion: evidence.contactRevision.schemaVersion, source: evidence.contactRevision.source, revision: evidence.contactRevision.revision },
8
- providerRef: evidence.providerRef, carrierRef: evidence.carrierRef, serviceRef: evidence.serviceRef,
74
+ commandFingerprint,
75
+ contactRevision: { schemaVersion: evidence.contactRevision.schemaVersion, source, revision: evidence.contactRevision.revision },
76
+ providerRef, carrierRef, serviceRef,
77
+ ...(transcript ? { transcript } : privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}),
78
+ ...(evidence.mapping ? { mapping: { source: "dispatch_worker" as const, mode: mode!, shadowMode: evidence.mapping.mode === "shadow" } } : {}),
9
79
  };
10
80
  }
81
+ /** Shadow has a local command identity; it never invokes a provider or resolves credentials. */
82
+ export function shadowDispatchEvidence(request: FulfillmentDispatchRequest, commandFingerprint?: string): DispatchEvidence {
83
+ if (!request.selection.providerRef || !request.lines.length || request.lines.some(line => !Number.isSafeInteger(line.quantity) || line.quantity <= 0)) throw new Error("dispatch_shadow_command_invalid");
84
+ return dispatchEvidence({ commandFingerprint: commandFingerprint ?? createHash("sha256").update(JSON.stringify(request)).digest("hex"),
85
+ contactRevision: request.contactRevision, providerRef: request.selection.providerRef,
86
+ carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef,
87
+ transcript: { itemCount: request.lines.length, items: request.lines.map(line => ({ skuRef: line.skuRef, quantity: line.quantity,
88
+ lotRef: line.lotRef ?? null, expirationDate: line.expirationDate ?? null })),
89
+ deliveryKind: request.selection.deliveryKind, pickupPointRef: request.selection.pickupPoint?.id ?? null } });
90
+ }
11
91
  export function dispatchResult(result: FulfillmentDispatchResult): FulfillmentDispatchResult {
92
+ const privacy = durableStrings(result.redactedFieldCount);
93
+ const externalRef = result.kind === "accepted" ? privacy.token(result.externalRef) ?? "redacted" : null;
94
+ const refusal = result.kind !== "accepted" ? { reason: (privacy.token(result.refusal.reason) ?? "malformed") as typeof result.refusal.reason,
95
+ phase: (privacy.token(result.refusal.phase) ?? "normalize") as typeof result.refusal.phase, retryable: result.refusal.retryable } : null;
96
+ const redactions = privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {};
97
+ const error = result.error ? { error: operationError({ ...result.error, mayHaveSucceeded: result.kind !== "not_dispatched" }) } : {};
12
98
  switch (result.kind) {
13
- case "accepted": return { kind: "accepted", externalRef: result.externalRef, providerAttemptKey: result.providerAttemptKey, labelKey: result.labelKey, evidence: dispatchEvidence(result.evidence) };
14
- case "not_dispatched": return { kind: "not_dispatched", effectMayHaveSucceeded: false, refusal: { reason: result.refusal.reason, phase: result.refusal.phase, retryable: result.refusal.retryable } };
15
- case "unknown": return { kind: "unknown", effectMayHaveSucceeded: true, refusal: { reason: result.refusal.reason, phase: result.refusal.phase, retryable: result.refusal.retryable } };
99
+ case "accepted": return { kind: "accepted", externalRef: externalRef!, providerAttemptKey: result.providerAttemptKey, labelKey: result.labelKey, ...redactions, evidence: dispatchEvidence(result.evidence), ...error,
100
+ ...(result.recovery ? { recovery: { source: "persisted_acceptance", proof: "persisted_external_ref", noProviderPost: true } } : {}) };
101
+ case "not_dispatched": return { kind: "not_dispatched", effectMayHaveSucceeded: false, refusal: refusal!, ...redactions, ...error };
102
+ case "unknown": return { kind: "unknown", effectMayHaveSucceeded: true, refusal: refusal!, ...redactions, ...error };
16
103
  }
17
104
  }
18
105
  export function acceptedProviderProof(record: DispatchRecord | null): record is DispatchRecord & { externalRef: string } {
@@ -5,9 +5,10 @@ export { createOutboxOrderPaidFulfillmentHandler } from "./paidHandoff.js";
5
5
  export { collectPaidFulfillmentRecoveryCandidates } from "./paidFulfillmentRecovery.js";
6
6
  export { runFulfillmentDispatchWorker } from "./providerDispatchWorker.js";
7
7
  export { createFulfillmentCommandLedger } from "./providerCommandLedger.js";
8
- export { verifyAndNormalizeFulfillmentObservation } from "./verifiedIngress.js";
8
+ export type { FulfillmentIngressCounts, FulfillmentIngressResult } from "./verifiedIngress.js";
9
+ export { verifyAndNormalizeFulfillmentObservation, fulfillmentIngressHttpResult } from "./verifiedIngress.js";
9
10
  export { applyFulfillmentObservationEffects } from "./fulfillmentObservationEffects.js";
10
- export { reconcileFulfillmentObservations } from "./providerReconciliationWorker.js";
11
+ export { reconcileFulfillmentObservations, runFulfillmentReconciliationPage } from "./providerReconciliationWorker.js";
11
12
  export { runFulfillmentStockSyncWorker } from "./providerStockSyncWorker.js";
12
13
  export { runFulfillmentProductSyncWorker } from "./providerProductSyncWorker.js";
13
14
  export { createFulfillmentLowStockEvidenceHandler } from "./lowStockEvidenceHandler.js";
@@ -5,4 +5,5 @@ export const FULFILLMENT_MIGRATIONS = [
5
5
  { position: 51, catalogPath: "db/platform/migrations/20260819150000_fulfillment_shipment_sequence.sql", sha256: "906706cf66a6a555aa1219b12eb674d937eaa099abde6da18766310af43aaa9f", url: new URL("../../sql/20260819150000_fulfillment_shipment_sequence.sql", import.meta.url) },
6
6
  { position: 73, catalogPath: "db/platform/migrations/20261022080000_fulfillment_command_ledger.sql", sha256: "c6806906cdb0b6931b29f7a5c7c800016e98d04e6ea956f3c1e219a6cdb7e9d1", url: new URL("../../sql/20261022080000_fulfillment_command_ledger.sql", import.meta.url) },
7
7
  { position: 74, catalogPath: "db/platform/migrations/20261022090000_fulfillment_delivery_alignment.sql", sha256: "d9966ae79fe77a0feae8317dc5b46ef2d8196dd0790b1cd0ee1b06ab58061499", url: new URL("../../sql/20261022090000_fulfillment_delivery_alignment.sql", import.meta.url) },
8
+ { position: 76, catalogPath: "db/platform/migrations/20261022165000_fulfillment_stock_evidence_replay_reopen.sql", sha256: "6d132dec2820047f4e337bcf752b3d884600ccc12de03887fe6d6aa5575bc1db", url: new URL("../../sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql", import.meta.url) },
8
9
  ] as const;
@@ -1,32 +1,83 @@
1
- import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation } from "./contracts/index.js";
1
+ import { z } from "zod";
2
+ import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentObservationCodecs } from "./contracts/index.js";
2
3
  import { parseFulfillmentFact } from "./normalizedFulfillmentFacts.js";
4
+ import { admittedReference, durableStrings, reference } from "./dispatchProjection.js";
3
5
  import { FULFILLMENT_STATUS_MAP } from "./statusMap.js";
4
6
 
5
- export function projectCorrelation(value: FulfillmentObservation["correlation"]): FulfillmentObservation["correlation"] {
6
- const { orderRef, fulfillmentRef, externalRef, orderNumber, providerFulfillmentRef, parcelRef } = value;
7
- return { orderRef, fulfillmentRef, externalRef, orderNumber, providerFulfillmentRef, parcelRef };
7
+ type Privacy = ReturnType<typeof durableStrings>;
8
+ const timestamp = z.iso.datetime({ offset: true });
9
+ export function projectCorrelation(value: FulfillmentObservation["correlation"], privacy = durableStrings(), codecs: FulfillmentObservationCodecs = {}): FulfillmentObservation["correlation"] {
10
+ const token = (value: string | null) => privacy.token(value, text => admittedReference(text, codecs.approveReference));
11
+ return { orderRef: value.orderRef, fulfillmentRef: value.fulfillmentRef, externalRef: token(value.externalRef),
12
+ orderNumber: token(value.orderNumber), providerFulfillmentRef: token(value.providerFulfillmentRef), parcelRef: value.parcelRef };
13
+ }
14
+
15
+ export function projectProviderStatus(value: NonNullable<FulfillmentObservation["providerStatus"]>, privacy = durableStrings()): NonNullable<FulfillmentObservation["providerStatus"]> {
16
+ return { primary: privacy.token(value.primary), subStatus: privacy.token(value.subStatus) };
17
+ }
18
+ export type PollEvidence = Pick<FulfillmentObservation, "correlation" | "eventKey" | "providerStatus" | "diagnostics">;
19
+ function pollEvidence(value: PollEvidence, privacy: Privacy, codecs: FulfillmentObservationCodecs = {}): PollEvidence {
20
+ // Host identities and keys composed from admitted parts are already authoritative.
21
+ return { eventKey: value.eventKey, correlation: projectCorrelation(value.correlation, privacy, codecs),
22
+ ...(value.providerStatus ? { providerStatus: projectProviderStatus(value.providerStatus, privacy) } : {}) };
23
+ }
24
+ /** Unknown-status quarantine/ACK evidence has the same privacy boundary as observations. */
25
+ export function projectPollEvidence(value: PollEvidence): PollEvidence {
26
+ const privacy = durableStrings(value.diagnostics?.redactedFieldCount);
27
+ const safe = pollEvidence(value, privacy);
28
+ return { ...safe, ...(privacy.redactedFieldCount ? { diagnostics: { redactedFieldCount: privacy.redactedFieldCount } } : {}) };
29
+ }
30
+
31
+ /** One URL policy: strict default, optional adopter codec, and approval at every sink. */
32
+ export function publicTrackingUrl(value: string, approve?: (url: string) => boolean, codec?: (url: string) => string | null): string | null {
33
+ try {
34
+ const candidate = codec ? codec(value) : value;
35
+ if (!candidate) return null;
36
+ const url = new URL(candidate);
37
+ return url.protocol === "https:" && !url.username && !url.password && (codec || (!url.search && !url.hash)) && approve?.(url.href) ? url.href : null;
38
+ } catch { return null; }
39
+ }
40
+ export function projectTrackingReference(value: FulfillmentObservation["trackingRefs"][number], privacy = durableStrings(), approveTrackingUrl?: (url: string) => boolean, codecs: FulfillmentObservationCodecs = {}) {
41
+ const token = (value: string | null) => privacy.token(value, text => admittedReference(text, codecs.approveReference));
42
+ const trackingNumber = token(value.trackingNumber);
43
+ if (trackingNumber === null) return null;
44
+ const serviceRef = token(value.serviceRef);
45
+ // Even an opaque-looking derived carrier is unsafe when its service was refused.
46
+ const carrierRef = privacy.token(value.carrierRef, text => value.serviceRef !== null && serviceRef === null ? null : reference(text));
47
+ return { trackingNumber, carrierRef, serviceRef,
48
+ publicUrl: privacy.token(value.publicUrl, url => publicTrackingUrl(url, approveTrackingUrl, codecs.trackingUrl)) };
8
49
  }
9
50
 
10
51
  /** New boundary projection: arbitrary adapter properties are never durable. */
11
- export function projectObservation(value: FulfillmentObservation): FulfillmentObservation {
52
+ export function projectObservation(value: FulfillmentObservation, approveTrackingUrl?: (url: string) => boolean, codecs: FulfillmentObservationCodecs = {}): FulfillmentObservation {
53
+ const privacy = durableStrings(value.diagnostics?.redactedFieldCount), token = privacy.token;
12
54
  const diagnostics: NonNullable<FulfillmentObservation["diagnostics"]> = {};
55
+ if (value.diagnostics?.admission) {
56
+ const count = (value: number) => Number.isSafeInteger(value) && value > 0 ? value : 0;
57
+ diagnostics.admission = { rejectedTrackingRefs: count(value.diagnostics.admission.rejectedTrackingRefs), rejectedTrackingUrls: count(value.diagnostics.admission.rejectedTrackingUrls) };
58
+ }
13
59
  if (value.diagnostics?.refusal) {
14
60
  const { reason, phase, retryable } = value.diagnostics.refusal;
15
- diagnostics.refusal = { reason, phase, retryable };
61
+ diagnostics.refusal = { reason: token(reason) as typeof reason, phase: token(phase) as typeof phase, retryable };
16
62
  }
17
63
  if (value.diagnostics?.stockContention) diagnostics.stockContention = { sqlState: "55P03", phase: "provider_stock_consumed", retryable: true };
18
- if (value.diagnostics?.annotation) diagnostics.annotation = value.diagnostics.annotation;
64
+ if (value.diagnostics?.annotation) diagnostics.annotation = token(value.diagnostics.annotation) as typeof value.diagnostics.annotation;
19
65
  if (value.diagnostics?.recoveredPickedReceipt) {
20
66
  const { receiptRef, closed } = value.diagnostics.recoveredPickedReceipt;
21
67
  diagnostics.recoveredPickedReceipt = { receiptRef, closed };
22
68
  }
23
- return {
24
- fact: parseFulfillmentFact(value.fact), eventKind: value.eventKind, eventKey: value.eventKey,
25
- firstReceivedAt: value.firstReceivedAt,
26
- correlation: projectCorrelation(value.correlation),
27
- trackingRefs: value.trackingRefs.map(({ trackingNumber, carrierRef, serviceRef, publicUrl }) => ({ trackingNumber, carrierRef, serviceRef, publicUrl })),
28
- ...(Object.keys(diagnostics).length ? { diagnostics } : {}),
69
+ const fact = parseFulfillmentFact(value.fact);
70
+ const safe = {
71
+ ...pollEvidence(value, privacy, codecs),
72
+ fact: { ...fact, mapperVersion: token(fact.mapperVersion) ?? "redacted" }, eventKind: (token(value.eventKind) ?? "redacted") as FulfillmentObservation["eventKind"],
73
+ firstReceivedAt: timestamp.parse(value.firstReceivedAt),
74
+ trackingRefs: value.trackingRefs.flatMap(ref => {
75
+ const projected = projectTrackingReference(ref, privacy, approveTrackingUrl, codecs);
76
+ return projected ? [projected] : [];
77
+ }),
29
78
  };
79
+ if (privacy.redactedFieldCount) diagnostics.redactedFieldCount = privacy.redactedFieldCount;
80
+ return { ...safe, ...(Object.keys(diagnostics).length ? { diagnostics } : {}) };
30
81
  }
31
82
 
32
83
  const HANDOVER_STATUSES: ReadonlySet<string> = new Set(
@@ -45,7 +45,7 @@ export interface OmnipackClient {
45
45
  createProduct(payload: Record<string, unknown>, sku: string): Promise<OmnipackProductCreatedEvidence>;
46
46
  getProduct(sku: string): Promise<OmnipackProductEvidence>;
47
47
  createProductPack(sku: string, payload: Record<string, unknown>, ean: string): Promise<OmnipackProductPackCreatedEvidence>;
48
- getStock(): Promise<OmnipackStockEvidence[]>;
48
+ getStock(options?: { refuseRedirectRetry: boolean }): Promise<OmnipackStockEvidence[]>;
49
49
  getStockMovements(params?: OmnipackQueryParams): Promise<OmnipackStockMovementEvidence[]>;
50
50
  getFulfilmentsPage(params?: OmnipackQueryParams): Promise<{ rows: OmnipackFulfilmentEvidence[]; complete: boolean }>;
51
51
  getFulfilments(params?: OmnipackQueryParams): Promise<OmnipackFulfilmentEvidence[]>;
@@ -87,8 +87,8 @@ export function createOmnipackClient(
87
87
  return mapOmnipackProductPackCreatedResponse(body, sku, ean);
88
88
  },
89
89
 
90
- async getStock() {
91
- return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }));
90
+ async getStock(options) {
91
+ return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }, options?.refuseRedirectRetry));
92
92
  },
93
93
 
94
94
  async getStockMovements(params) {
@@ -98,6 +98,7 @@ export function createOmnipackClient(
98
98
  "getStockMovements",
99
99
  withQuery("/stock-movements", params),
100
100
  { method: "GET" },
101
+ true,
101
102
  ));
102
103
  },
103
104
 
@@ -141,43 +142,54 @@ async function requestJson(
141
142
  operation: string,
142
143
  path: string,
143
144
  init: RequestInit,
145
+ refuseRedirectRetry = false,
144
146
  ): Promise<unknown> {
145
147
  const method = (init.method ?? "GET").toUpperCase();
146
148
  const attempts = method === "GET" ? config.safeReadRetries + 1 : 1;
147
- let lastError: unknown = null;
149
+ // Dispatch/readback diagnostics must not change product-sync or stock retry policy.
150
+ const diagnoseRedirectFailure = refuseRedirectRetry || operation === "createOrder"
151
+ || operation === "getFulfilments" || operation === "getFulfilmentRequests";
152
+ let lastError: OmnipackProviderError | null = null;
148
153
 
149
154
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
150
155
  let submitted = false;
156
+ let responseBodyRead: boolean | undefined;
157
+ const dispatchFacts = () => operation === "createOrder" ? { submissionAttempted: submitted,
158
+ ...(responseBodyRead !== undefined ? { responseBodyRead } : {}) } : {};
151
159
  try {
152
160
  const base = new URL(config.baseUrl);
153
161
  const url = new URL(path, base);
154
162
  if (url.origin !== base.origin || url.username || url.password || url.protocol !== "https:") throw new Error("Invalid fulfillment path");
155
163
  const response = await fetchWithTimeout(fetchImpl, url, config, init, () => { submitted = true; });
156
- if (response.ok) return await readJsonBody(response);
164
+ responseBodyRead = false;
165
+ const body = await readJsonBody(response).then(value => { responseBodyRead = true; return value; }).catch(error => {
166
+ if (response.ok) throw error;
167
+ return null;
168
+ });
169
+ if (response.ok) return body;
157
170
 
158
171
  const retryable = method === "GET" && isRetryableStatus(response.status);
159
172
  // A failed body read cannot erase the rejection status already received.
160
- const body = await readJsonBody(response).catch(() => null);
161
- lastError = new OmnipackProviderError({
173
+ lastError = Object.assign(new OmnipackProviderError({
162
174
  operation,
163
175
  status: response.status,
164
- code: readProviderErrorCode(body) ?? `http_${response.status}`,
176
+ code: refuseRedirectRetry && response.status >= 300 && response.status < 400 ? "redirect_refused" : readProviderErrorCode(body) ?? `http_${response.status}`,
165
177
  retryable,
166
178
  mayHaveSucceeded: mayPostHaveSucceeded(method, response.status),
167
- });
179
+ }), dispatchFacts());
168
180
  if (!retryable || attempt === attempts) throw lastError;
169
181
  } catch (error) {
170
182
  const retryable = method === "GET" && attempt < attempts;
171
183
  lastError = submitted && error instanceof OmnipackProviderError
172
184
  ? error
173
- : new OmnipackProviderError({
185
+ : Object.assign(new OmnipackProviderError({
174
186
  operation,
175
187
  status: null,
176
- code: "network_error",
188
+ code: diagnoseRedirectFailure && isRedirectRefusal(error) ? "redirect_refused" : "network_error",
177
189
  retryable,
178
190
  mayHaveSucceeded: submitted && mayPostHaveSucceeded(method, null),
179
- });
180
- if (!retryable) throw lastError;
191
+ }), dispatchFacts());
192
+ if (!retryable || (refuseRedirectRetry && lastError.code === "redirect_refused")) throw lastError;
181
193
  }
182
194
  }
183
195
 
@@ -256,3 +268,10 @@ function readProviderErrorCode(body: unknown): string | null {
256
268
  function isRetryableStatus(status: number): boolean {
257
269
  return status === 408 || status === 429 || status >= 500;
258
270
  }
271
+
272
+ // Fetch implementations expose redirect refusal as an exception, sometimes through cause.
273
+ // Inspect it only to select a closed diagnostic; no exception text leaves this adapter.
274
+ function isRedirectRefusal(error: unknown): boolean {
275
+ if (!(error instanceof Error)) return false;
276
+ return /redirect/i.test(error.message) || (error.cause instanceof Error && /redirect/i.test(error.cause.message));
277
+ }