@twin.org/dataspace-control-plane-service 0.0.3-next.45 → 0.0.3-next.47

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.
@@ -93,6 +93,12 @@ export class DataspaceControlPlaneService {
93
93
  * @internal
94
94
  */
95
95
  _dataPlanePath;
96
+ /**
97
+ * Control plane callback mount path (path only, not full URL). Combined with this node's public
98
+ * origin to form the consumer callbackAddress the provider POSTs DSP transfer messages back to.
99
+ * @internal
100
+ */
101
+ _callbackPath;
96
102
  /**
97
103
  * Factory key used to look up the data plane component. Resolved lazily at push-time
98
104
  * (not at construction) so the data plane may register after the control plane is built.
@@ -158,6 +164,9 @@ export class DataspaceControlPlaneService {
158
164
  this._dataPlanePath = Is.stringValue(options?.config?.dataPlanePath)
159
165
  ? StringHelper.trimTrailingSlashes(StringHelper.trimLeadingSlashes(options.config.dataPlanePath))
160
166
  : undefined;
167
+ this._callbackPath = Is.stringValue(options?.config?.callbackPath)
168
+ ? StringHelper.trimLeadingAndTrailingSlashes(options.config.callbackPath)
169
+ : undefined;
161
170
  this._taskScheduler = ComponentFactory.getIfExists(options?.taskSchedulerComponentType ?? "task-scheduler");
162
171
  this._platformComponent = ComponentFactory.getIfExists(options?.platformComponentType ?? "platform");
163
172
  // Data plane component is optional and resolved lazily. The control plane is initialised
@@ -332,13 +341,18 @@ export class DataspaceControlPlaneService {
332
341
  * Request a Transfer Process.
333
342
  * Creates a new Transfer Process in REQUESTED state.
334
343
  * @param request Transfer request message (DSP compliant).
344
+ * @param publicOrigin The public origin of this provider node, resolved by the REST route from the
345
+ * hosting component; used to build the data-plane endpoint when auto-starting.
346
+ * @param options Request options.
347
+ * @param options.autoStart When true, the provider immediately starts the requested transfer (scheduled
348
+ * on the next tick); when omitted/false the provider start must be triggered explicitly.
335
349
  * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
336
350
  * @returns Transfer Process (DSP compliant) with state REQUESTED, or TransferError if the operation fails.
337
351
  *
338
352
  * Role Performed: Provider
339
353
  * Called by: Consumer when it wants to request a new Transfer Process
340
354
  */
341
- async requestTransfer(request, trustPayload) {
355
+ async requestTransfer(request, publicOrigin, options, trustPayload) {
342
356
  const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "requestTransfer");
343
357
  const validationFailures = await DataspaceProtocolHelper.validate(JsonLdHelper.toNodeObject(request));
344
358
  if (Is.arrayValue(validationFailures)) {
@@ -405,6 +419,7 @@ export class DataspaceControlPlaneService {
405
419
  datasetId,
406
420
  consumerIdentity,
407
421
  providerIdentity,
422
+ localRole: TransferProcessRole.Provider,
408
423
  // offerId should reference Catalog Offer (via Agreement)
409
424
  // For now, use agreementId as reference (proper flow: Catalog → Negotiation → Agreement)
410
425
  offerId: request.agreementId,
@@ -429,6 +444,16 @@ export class DataspaceControlPlaneService {
429
444
  agreementId: request.agreementId
430
445
  }
431
446
  });
447
+ // When auto-start is requested, schedule the provider start asynchronously (mirrors negotiation's
448
+ // setTimeout follow-up). The timer runs inside the request's ALS context, so the [Node, Tenant] + org
449
+ // partition propagates to the deferred start.
450
+ if (options?.autoStart) {
451
+ const consumerPid = request.consumerPid;
452
+ setTimeout(async () => {
453
+ // runProviderStart catches internally and can't reject; awaiting satisfies no-floating-promises.
454
+ await this.runProviderStart(consumerPid, publicOrigin);
455
+ }, 0);
456
+ }
432
457
  return {
433
458
  "@context": [DataspaceProtocolContexts.Context],
434
459
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -438,7 +463,7 @@ export class DataspaceControlPlaneService {
438
463
  };
439
464
  }
440
465
  /**
441
- * Start a data transfer as a Consumer.
466
+ * Prepare a data transfer as a Consumer.
442
467
  * Generates a consumerPid, POSTs a TransferRequestMessage to the provider's DSP endpoint,
443
468
  * and (only if the provider accepts) persists a local TransferProcess in REQUESTED state.
444
469
  * @param agreementId The finalized agreement ID from contract negotiation.
@@ -456,7 +481,7 @@ export class DataspaceControlPlaneService {
456
481
  * registration ignores the runtime `endpoint` arg and silently POSTs to its
457
482
  * static endpoint instead.
458
483
  */
459
- async startDataTransfer(agreementId, providerEndpoint, publicOrigin, format, trustPayload) {
484
+ async prepareTransfer(agreementId, providerEndpoint, publicOrigin, format, trustPayload) {
460
485
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "agreementId", agreementId);
461
486
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "providerEndpoint", providerEndpoint);
462
487
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "publicOrigin", publicOrigin);
@@ -467,12 +492,12 @@ export class DataspaceControlPlaneService {
467
492
  supported: Object.values(DataspaceTransferFormat)
468
493
  });
469
494
  }
470
- const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "startDataTransfer");
495
+ const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "prepareTransfer");
471
496
  await this._loggingComponent?.log({
472
497
  level: "info",
473
498
  source: DataspaceControlPlaneService.CLASS_NAME,
474
499
  ts: Date.now(),
475
- message: "startingDataTransfer",
500
+ message: "preparingTransfer",
476
501
  data: { agreementId, providerEndpoint, format, identity: trustInfo.identity }
477
502
  });
478
503
  const agreement = await this.lookupAgreement(agreementId);
@@ -489,9 +514,18 @@ export class DataspaceControlPlaneService {
489
514
  const providerIdentity = assignerIds[0];
490
515
  const datasetId = this.extractDatasetId(agreement);
491
516
  const consumerPid = `urn:uuid:${RandomHelper.generateUuidV7()}`;
492
- const callbackAddress = StringHelper.trimTrailingSlashes(publicOrigin);
493
- // Fetch context once and reuse across the PUSH dataAddress, trust token, and storage entity.
517
+ const origin = StringHelper.trimTrailingSlashes(publicOrigin);
518
+ // Fetch context once and reuse across the callback, PUSH dataAddress, trust token, and storage.
494
519
  const organizationIdentity = await this.resolveContextOrganizationId();
520
+ // Callback the provider POSTs DSP messages back to: mount path + `?organization=` so the inbound
521
+ // POST routes to the right consumer tenant (mirrors buildCallbackUrl). The bare `origin` is kept for
522
+ // the PUSH-inbox base below (a different mount).
523
+ let callbackAddress = Is.stringValue(this._callbackPath)
524
+ ? `${origin}/${this._callbackPath}`
525
+ : origin;
526
+ if (Is.stringValue(organizationIdentity)) {
527
+ callbackAddress = HttpUrlHelper.addQueryStringParam(callbackAddress, ContextIdKeys.Organization, organizationIdentity);
528
+ }
495
529
  const transferRequestMessage = {
496
530
  "@context": [DataspaceProtocolContexts.Context],
497
531
  "@type": DataspaceProtocolTransferProcessTypes.TransferRequestMessage,
@@ -507,7 +541,7 @@ export class DataspaceControlPlaneService {
507
541
  if (!Is.stringValue(this._dataPlanePath)) {
508
542
  throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid });
509
543
  }
510
- let inboxEndpoint = `${callbackAddress}/${this._dataPlanePath}/inbox`;
544
+ let inboxEndpoint = `${origin}/${this._dataPlanePath}/inbox`;
511
545
  if (Is.stringValue(organizationIdentity)) {
512
546
  inboxEndpoint = HttpUrlHelper.addQueryStringParam(inboxEndpoint, ContextIdKeys.Organization, organizationIdentity);
513
547
  }
@@ -521,7 +555,7 @@ export class DataspaceControlPlaneService {
521
555
  const outboundToken = await this._trustComponent.generate(organizationIdentity, this._overrideTrustGeneratorType, { subject: { consumerPid, agreementId } });
522
556
  // Create a remote REST client pointed at the provider endpoint and call requestTransfer.
523
557
  const remoteControlPlane = ComponentFactory.create(this._remoteControlPlaneComponentType, { endpoint: providerEndpoint, pathPrefix: "" });
524
- const result = await remoteControlPlane.requestTransfer(transferRequestMessage, outboundToken);
558
+ const result = await remoteControlPlane.requestTransfer(transferRequestMessage, "", undefined, outboundToken);
525
559
  if (getJsonLdType(result) === DataspaceProtocolTransferProcessTypes.TransferError) {
526
560
  const transferError = result;
527
561
  throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "transferRequestRejectedByProvider", {
@@ -544,6 +578,7 @@ export class DataspaceControlPlaneService {
544
578
  datasetId,
545
579
  consumerIdentity: trustInfo.identity,
546
580
  providerIdentity,
581
+ localRole: TransferProcessRole.Consumer,
547
582
  offerId: agreementId,
548
583
  policies: [agreement],
549
584
  callbackAddress,
@@ -558,7 +593,7 @@ export class DataspaceControlPlaneService {
558
593
  level: "info",
559
594
  source: DataspaceControlPlaneService.CLASS_NAME,
560
595
  ts: Date.now(),
561
- message: "dataTransferStarted",
596
+ message: "transferPrepared",
562
597
  data: {
563
598
  consumerPid,
564
599
  providerPid: storageEntity.providerPid,
@@ -613,6 +648,28 @@ export class DataspaceControlPlaneService {
613
648
  currentState: entity.state
614
649
  }), message);
615
650
  }
651
+ // CONSUMER-RECEIVE: the provider POSTed a TransferStart to our callback. Use the dataAddress
652
+ // from the message (rebuilding it is provider work), mark our record STARTED, and notify.
653
+ if (role === TransferProcessRole.Consumer) {
654
+ entity.state = DataspaceProtocolTransferProcessStateType.STARTED;
655
+ entity.dateModified = new Date();
656
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
657
+ await this._loggingComponent?.log({
658
+ level: "info",
659
+ source: DataspaceControlPlaneService.CLASS_NAME,
660
+ ts: Date.now(),
661
+ message: "transferProcessStarted",
662
+ data: {
663
+ consumerPid: entity.consumerPid,
664
+ providerPid: entity.providerPid,
665
+ role
666
+ }
667
+ });
668
+ await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.STARTED);
669
+ await this._internalTransferCallback.onStarted(entity.consumerPid, message);
670
+ return message;
671
+ }
672
+ // PROVIDER-ACT (role === Provider): build the dataAddress, transition to STARTED, deliver below.
616
673
  // The previous (pre-transition) state drives the push-subscription branch below
617
674
  // (setup vs resume). Do NOT mutate entity.state here as the persisted transition
618
675
  // to STARTED happens after the dispatch block succeeds, so any validation or
@@ -829,16 +886,60 @@ export class DataspaceControlPlaneService {
829
886
  role
830
887
  }
831
888
  });
832
- if (role === TransferProcessRole.Consumer) {
833
- await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.STARTED);
834
- await this._internalTransferCallback.onStarted(entity.consumerPid, response);
835
- }
889
+ // Notify the consumer by POSTing the TransferStart (with its dataAddress) to the callback.
890
+ // Best-effort: a delivery failure must not roll back the STARTED transition.
891
+ await this.deliverToConsumerCallback(entity, "start", async (remoteControlPlane, token) => remoteControlPlane.startTransfer(response, "", token));
836
892
  return response;
837
893
  }
838
894
  catch (error) {
839
895
  return transformToTransferError(error, message);
840
896
  }
841
897
  }
898
+ /**
899
+ * Start a Transfer Process as the Provider.
900
+ * Builds a TransferStartMessage for a transfer already accepted by this node (REQUESTED, or
901
+ * SUSPENDED to resume), transitions it to STARTED, and POSTs the message to the consumer callback.
902
+ * consumerPid/providerPid/callbackAddress are resolved from the stored record; the call is then
903
+ * forwarded to startTransfer, the single owner of the start state machine (it verifies the caller is
904
+ * the provider, builds the dataAddress for PULL, persists STARTED, and delivers to the consumer).
905
+ * This is the provider-side mirror of prepareTransfer.
906
+ * @param pid The Process ID (consumerPid or providerPid) identifying the transfer to start.
907
+ * @param publicOrigin The public origin URL of this provider node (used to build the data plane endpoint for PULL transfers).
908
+ * @param trustPayload Trust payload proving the caller is the provider.
909
+ * @returns Transfer Start Message (DSP compliant) with dataAddress for PULL transfers, or TransferError if the operation fails.
910
+ */
911
+ async transferStarted(pid, publicOrigin, trustPayload) {
912
+ let consumerPid;
913
+ let providerPid;
914
+ try {
915
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "pid", pid);
916
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "publicOrigin", publicOrigin);
917
+ const { entity, role } = await this.lookupTransferByPid(pid);
918
+ consumerPid = entity.consumerPid;
919
+ providerPid = entity.providerPid;
920
+ // Only the provider initiates a start. startTransfer branches on role, so guard here to give a
921
+ // clear error instead of silently running the consumer-receive path on a consumer-role record.
922
+ if (role !== TransferProcessRole.Provider) {
923
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "transferStartNotProvider", {
924
+ pid,
925
+ role
926
+ });
927
+ }
928
+ const startMessage = {
929
+ "@context": [DataspaceProtocolContexts.Context],
930
+ "@type": DataspaceProtocolTransferProcessTypes.TransferStartMessage,
931
+ consumerPid: entity.consumerPid,
932
+ providerPid: entity.providerPid
933
+ };
934
+ // startTransfer verifies trustPayload is the provider, checks org ownership, applies the
935
+ // REQUESTED|SUSPENDED state guard, builds the dataAddress, persists STARTED, and delivers the
936
+ // TransferStartMessage to the consumer callback.
937
+ return await this.startTransfer(startMessage, publicOrigin, trustPayload);
938
+ }
939
+ catch (error) {
940
+ return transformToTransferError(error, { consumerPid, providerPid });
941
+ }
942
+ }
842
943
  // ----------------------------------------------------------------------------
843
944
  // SHARED STATE MANAGEMENT OPERATIONS (Either Side)
844
945
  // ----------------------------------------------------------------------------
@@ -919,6 +1020,8 @@ export class DataspaceControlPlaneService {
919
1020
  throw teardownError;
920
1021
  }
921
1022
  }
1023
+ // Completion is consumer-initiated (the auth above accepts only the consumer): a consumer→provider
1024
+ // notification, so no provider→consumer delivery here — unlike suspend/terminate (either party).
922
1025
  if (role === TransferProcessRole.Consumer) {
923
1026
  await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.COMPLETED);
924
1027
  await this._internalTransferCallback.onCompleted(entity.consumerPid);
@@ -1021,6 +1124,10 @@ export class DataspaceControlPlaneService {
1021
1124
  : message.reason;
1022
1125
  await this._internalTransferCallback.onSuspended(entity.consumerPid, suspendReason);
1023
1126
  }
1127
+ else {
1128
+ // PROVIDER-ACT: forward the suspension (with its reason) to the consumer callback.
1129
+ await this.deliverToConsumerCallback(entity, "suspension", async (remoteControlPlane, token) => remoteControlPlane.suspendTransfer(message, token));
1130
+ }
1024
1131
  return {
1025
1132
  "@context": [DataspaceProtocolContexts.Context],
1026
1133
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -1113,6 +1220,10 @@ export class DataspaceControlPlaneService {
1113
1220
  : message.reason;
1114
1221
  await this._internalTransferCallback.onTerminated(entity.consumerPid, terminateReason);
1115
1222
  }
1223
+ else {
1224
+ // PROVIDER-ACT: forward the termination (with its reason) to the consumer callback.
1225
+ await this.deliverToConsumerCallback(entity, "termination", async (remoteControlPlane, token) => remoteControlPlane.terminateTransfer(message, token));
1226
+ }
1116
1227
  return {
1117
1228
  "@context": [DataspaceProtocolContexts.Context],
1118
1229
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -1676,6 +1787,128 @@ export class DataspaceControlPlaneService {
1676
1787
  });
1677
1788
  }
1678
1789
  }
1790
+ /**
1791
+ * Perform the provider-side start of a just-requested transfer (build dataAddress, transition to
1792
+ * STARTED, deliver to the consumer). Only invoked when the request opted into auto-start, so there is no
1793
+ * approval step. Self-contained: failures are logged, never thrown. Runs inside the request's ALS
1794
+ * context, so the deferred storage access inherits the [Node, Tenant] + org partition.
1795
+ * @param consumerPid The consumerPid of the requested transfer.
1796
+ * @param publicOrigin This provider node's public origin, used to build the data-plane endpoint.
1797
+ * @internal
1798
+ */
1799
+ async runProviderStart(consumerPid, publicOrigin) {
1800
+ try {
1801
+ const { entity } = await this.lookupTransferByPid(consumerPid);
1802
+ if (entity.state !== DataspaceProtocolTransferProcessStateType.REQUESTED) {
1803
+ // Already advanced (e.g. an explicit start raced the auto-start). Nothing to do.
1804
+ return;
1805
+ }
1806
+ if (!Is.stringValue(entity.providerIdentity)) {
1807
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "providerIdentityMissing");
1808
+ }
1809
+ // Fail closed: without a public origin the data-plane endpoint in the dataAddress would be
1810
+ // broken, so hold the transfer rather than ship a relative/empty endpoint to the consumer.
1811
+ if (!Is.stringValue(publicOrigin)) {
1812
+ await this._loggingComponent?.log({
1813
+ level: "error",
1814
+ source: DataspaceControlPlaneService.CLASS_NAME,
1815
+ ts: Date.now(),
1816
+ message: "autoStartPublicOriginMissing",
1817
+ data: { consumerPid }
1818
+ });
1819
+ return;
1820
+ }
1821
+ // Self-token issued as the provider so startTransfer's provider-auth check passes. Auto-start's
1822
+ // caller auth already happened upstream at requestTransfer, so minting the proof here (rather than
1823
+ // requiring an inbound token) is the boundary-correct place for it; transferStarted forwards it as
1824
+ // the trustPayload.
1825
+ const selfToken = await this._trustComponent.generate(entity.providerIdentity, this._overrideTrustGeneratorType, {
1826
+ subject: {
1827
+ consumerPid: entity.consumerPid,
1828
+ providerPid: entity.providerPid,
1829
+ agreementId: entity.agreementId
1830
+ }
1831
+ });
1832
+ const result = await this.transferStarted(consumerPid, publicOrigin, selfToken);
1833
+ if (getJsonLdType(result) === DataspaceProtocolTransferProcessTypes.TransferError) {
1834
+ await this._loggingComponent?.log({
1835
+ level: "error",
1836
+ source: DataspaceControlPlaneService.CLASS_NAME,
1837
+ ts: Date.now(),
1838
+ message: "autoStartFailed",
1839
+ data: { consumerPid }
1840
+ });
1841
+ }
1842
+ }
1843
+ catch (error) {
1844
+ await this._loggingComponent?.log({
1845
+ level: "error",
1846
+ source: DataspaceControlPlaneService.CLASS_NAME,
1847
+ ts: Date.now(),
1848
+ message: "autoStartFailed",
1849
+ error: BaseError.fromError(error),
1850
+ data: { consumerPid }
1851
+ });
1852
+ }
1853
+ }
1854
+ /**
1855
+ * Deliver a transfer state-change DSP message to the consumer's callback (provider → consumer), via a
1856
+ * remote control-plane client and the supplied sender. Mirrors negotiation's sendOfferToConsumer.
1857
+ * Best-effort: failures are logged, not thrown, so delivery problems don't roll back the transition.
1858
+ * @param entity The transfer process (provider side).
1859
+ * @param messageKind Short label for logging (e.g. "start", "suspension", "termination").
1860
+ * @param send Performs the remote POST given the remote client and an outbound trust token.
1861
+ * @internal
1862
+ */
1863
+ async deliverToConsumerCallback(entity, messageKind, send) {
1864
+ if (!Is.stringValue(entity.callbackAddress) || !Is.stringValue(entity.providerIdentity)) {
1865
+ // Spec-allowed: no callback supplied → the consumer polls GET /transfers/:pid instead.
1866
+ return;
1867
+ }
1868
+ try {
1869
+ // Issue the token as the provider party (the agreement assigner) — the identity the consumer's
1870
+ // receive gate checks (`!== providerIdentity`), not the tenant-routing org. Keeps the two
1871
+ // identity spaces separate and works whether or not a node's org == its assigner DID.
1872
+ const outboundToken = await this._trustComponent.generate(entity.providerIdentity, this._overrideTrustGeneratorType, {
1873
+ subject: {
1874
+ consumerPid: entity.consumerPid,
1875
+ providerPid: entity.providerPid,
1876
+ agreementId: entity.agreementId
1877
+ }
1878
+ });
1879
+ const remoteControlPlane = ComponentFactory.create(this._remoteControlPlaneComponentType, { endpoint: entity.callbackAddress, pathPrefix: "" });
1880
+ const result = await send(remoteControlPlane, outboundToken);
1881
+ if (getJsonLdType(result) === DataspaceProtocolTransferProcessTypes.TransferError) {
1882
+ await this._loggingComponent?.log({
1883
+ level: "error",
1884
+ source: DataspaceControlPlaneService.CLASS_NAME,
1885
+ ts: Date.now(),
1886
+ message: "transferCallbackRejected",
1887
+ data: {
1888
+ messageKind,
1889
+ consumerPid: entity.consumerPid,
1890
+ providerPid: entity.providerPid,
1891
+ callbackAddress: entity.callbackAddress
1892
+ }
1893
+ });
1894
+ }
1895
+ }
1896
+ catch (error) {
1897
+ await this._loggingComponent?.log({
1898
+ level: "error",
1899
+ source: DataspaceControlPlaneService.CLASS_NAME,
1900
+ ts: Date.now(),
1901
+ message: "transferCallbackFailed",
1902
+ error: BaseError.fromError(error),
1903
+ data: {
1904
+ messageKind,
1905
+ consumerPid: entity.consumerPid,
1906
+ providerPid: entity.providerPid,
1907
+ callbackAddress: entity.callbackAddress
1908
+ }
1909
+ });
1910
+ }
1911
+ }
1679
1912
  /**
1680
1913
  * Convert a storage entity to model.
1681
1914
  * @param storageEntity The entity from storage.
@@ -1693,6 +1926,7 @@ export class DataspaceControlPlaneService {
1693
1926
  offerId: storageEntity.offerId,
1694
1927
  consumerIdentity: storageEntity.consumerIdentity,
1695
1928
  providerIdentity: storageEntity.providerIdentity,
1929
+ localRole: storageEntity.localRole,
1696
1930
  format: storageEntity.format,
1697
1931
  callbackAddress: storageEntity.callbackAddress,
1698
1932
  organizationIdentity: storageEntity.organizationIdentity,
@@ -1719,6 +1953,7 @@ export class DataspaceControlPlaneService {
1719
1953
  offerId: entity.offerId,
1720
1954
  consumerIdentity: entity.consumerIdentity,
1721
1955
  providerIdentity: entity.providerIdentity,
1956
+ localRole: entity.localRole,
1722
1957
  format: entity.format,
1723
1958
  callbackAddress: entity.callbackAddress,
1724
1959
  organizationIdentity: entity.organizationIdentity,
@@ -1847,25 +2082,27 @@ export class DataspaceControlPlaneService {
1847
2082
  */
1848
2083
  async lookupTransferByPid(pid) {
1849
2084
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "pid", pid);
1850
- // Check if pid is a consumerPid (primary key lookup)
1851
- const storageEntity = await this._transferProcessStorage.get(pid);
1852
- if (storageEntity) {
1853
- return {
1854
- entity: this.storageEntityToModel(storageEntity),
1855
- role: TransferProcessRole.Consumer
1856
- };
2085
+ // consumerPid is the primary key on BOTH nodes, so the matched key alone is not a reliable role
2086
+ // signal; locate the record by primary, then the providerPid secondary index.
2087
+ let storageEntity = await this._transferProcessStorage.get(pid);
2088
+ let matchedByConsumerPid = true;
2089
+ if (!storageEntity) {
2090
+ storageEntity = await this._transferProcessStorage.get(pid, "providerPid");
2091
+ matchedByConsumerPid = false;
1857
2092
  }
1858
- // Check if pid is a providerPid (secondary key lookup)
1859
- const providerPidEntity = await this._transferProcessStorage.get(pid, "providerPid");
1860
- if (providerPidEntity) {
1861
- return {
1862
- entity: this.storageEntityToModel(providerPidEntity),
1863
- role: TransferProcessRole.Provider
1864
- };
2093
+ if (!storageEntity) {
2094
+ throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessNotFound", pid, {
2095
+ pid
2096
+ });
1865
2097
  }
1866
- throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessNotFound", pid, {
1867
- pid
1868
- });
2098
+ // Prefer the role persisted at write time (set in prepareTransfer / requestTransfer). Fall back
2099
+ // to the matched-key heuristic only for legacy records written before localRole existed.
2100
+ const role = storageEntity.localRole ??
2101
+ (matchedByConsumerPid ? TransferProcessRole.Consumer : TransferProcessRole.Provider);
2102
+ return {
2103
+ entity: this.storageEntityToModel(storageEntity),
2104
+ role
2105
+ };
1869
2106
  }
1870
2107
  /**
1871
2108
  * Get raw policy entries from a catalog dataset.