> For the complete documentation index, see [llms.txt](https://docs.durohub.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.durohub.com/core-concepts/sourcing.md).

# Sourcing

Sourcing describes how a component gets built: the manufacturer parts (MPNs) that source it, the distributor quotes (DPNs) that price each part, and which of those sources is currently primary. A component can carry multiple parts, and each part multiple quotes — that is what lets you multi-source a component instead of committing to a single vendor.

This page covers the sourcing operations with practical examples. For the exact field-by-field shape of every type, introspect the schema in [Apollo Explorer](https://api.durohub.com/graphql) — the schema descriptions there are the source of truth.

## Overview

### The Sourcing Model

```
Component
  └─ Part            an MPN from a manufacturer
       ├─ Quote       a distributor's DPN offer — pricing, MOQ, lead time
       │    └─ Document (via QuoteDocument)
       └─ Document (via PartDocument)

Component
  └─ PrimarySource   the ranked list of quotes — rank 1 is the current primary
```

| Entity          | Identity           | Description                                                                                                                                                                                           |
| --------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Part`          | MPN + manufacturer | A manufacturer part that can source the component                                                                                                                                                     |
| `Quote`         | DPN + distributor  | A distributor's offer for a part                                                                                                                                                                      |
| `Vendor`        | —                  | Shared catalog entry; `types` says whether it acts as `MANUFACTURER`, `DISTRIBUTOR`, or both. Carries the vendor's own profile — `hqAddress`, `dunsNumber`, `taxId`                                   |
| `LibraryVendor` | —                  | A library's curated entry for a vendor, with an `approvalStatus`, the library's account details — `accountId`, `accountManager` — and computed [component counts](#component-counts-on-libraryvendor) |
| `PrimarySource` | —                  | One entry in a component's ranked source list                                                                                                                                                         |

### The `vendorSourcing` Namespace

All sourcing operations are namespaced: queries live under `Query.vendorSourcing` and mutations under `Mutation.vendorSourcing`. There are no root-level sourcing fields.

{% hint style="warning" %}
`mutation { applySourcingChangeset(...) }` fails. Wrap every sourcing operation:

```graphql
mutation {
  vendorSourcing {
    applySourcingChangeset(...)
  }
}
```

{% endhint %}

## Required Headers

All sourcing operations require the same headers as the rest of the API:

```bash
curl 'https://api.durohub.com/graphql' \
  -H 'x-api-key: YOUR_API_KEY' \
  -H 'x-organization: @your-org' \
  -H 'x-library: @your-org/your-library' \
  -H 'Content-Type: application/json' \
  -d '{"query": "..."}'
```

Sourcing operations never take a `libraryId` argument — the library comes from the `x-library` header.

***

## Querying Sourcing Data

### Parts on a Component

```graphql
query GetPartsForComponent {
  vendorSourcing {
    parts(filter: { componentId: "comp-123" }) {
      id
      mpn
      description
      manufacturer {
        name
      }
    }
  }
}
```

`part(id)` fetches a single part; `partHistory(partId)` returns its full version history. Parts carry `mpnUrl` — a link to the manufacturer's page for the MPN — accepted on the create and update inputs as well.

Parts also expose `isLegacyImport` (`Boolean`) — see [Legacy-import parts and quotes](#legacy-import-parts-and-quotes).

### Quotes

`quotes(partId)` lists a single part's quotes. To compare offers across every part on a component in one call, use `quotesByComponent`:

```graphql
query GetQuotesForComponent {
  vendorSourcing {
    quotesByComponent(componentId: "comp-123") {
      id
      dpn
      unitPrice
      leadTimeDays
      minQuantity
      distributor {
        name
      }
      part {
        mpn
        manufacturer {
          name
        }
      }
    }
  }
}
```

Quote fields that drive sourcing decisions:

| Field             | Type      | Description                                                                                                                |
| ----------------- | --------- | -------------------------------------------------------------------------------------------------------------------------- |
| `unitPrice`       | `Float`   | Price per unit. Nullable — a quote can be recorded before its price is known                                               |
| `minQuantity`     | `Int`     | The distributor's minimum order quantity                                                                                   |
| `maxQuantity`     | `Int`     | The distributor's cap, if any                                                                                              |
| `packageQuantity` | `Int`     | Units per reel, tube, or tray                                                                                              |
| `leadTimeDays`    | `Int`     | Distributor lead time (the part's `manufacturerLeadTimeDays` is separate)                                                  |
| `dpnUrl`          | `String`  | Link to the distributor's page for this DPN                                                                                |
| `description`     | `String`  | Free-text notes about the quote                                                                                            |
| `isLegacyImport`  | `Boolean` | `true` when the quote was created by the legacy cost migration rather than entered as a real distributor offer — see below |

{% hint style="warning" %}
`Quote.unitPrice` was previously non-nullable (`Float!`) and is now nullable. Clients that assumed a value is always present should handle `null`.
{% endhint %}

### Legacy-import parts and quotes

Before sourcing existed, a component's price lived in a single `Cost` attribute with no manufacturer or distributor attached. When that attribute was retired, those values were migrated onto the sourcing model. Because a quote requires a part and a distributor, the migration created placeholders: a sentinel distributor (`Unknown - Legacy Import`), a part whose MPN is the component's ID, and a quote carrying the old cost as its `unitPrice` with default package quantity and lead time.

`Part.isLegacyImport` and `Quote.isLegacyImport` mark those placeholder rows:

```graphql
query FindLegacySourcing {
  vendorSourcing {
    quotesByComponent(componentId: "comp-123") {
      id
      unitPrice
      isLegacyImport
      distributor {
        name
      }
      part {
        mpn
        isLegacyImport
      }
    }
  }
}
```

Both fields are read-only — they are set by the migration and are not accepted on `CreateQuoteInput`, `CreatePartInput`, or the update inputs. Anything you create through the API returns `false`.

{% hint style="info" %}
Treat a `true` flag as "the price is real, the rest of the row is not". The manufacturer, MPN, distributor, DPN, package quantity, and lead time on a legacy-import row are placeholders, so don't surface them as vendor data or use them to compare offers. Prompt users to replace the row with a real part and quote; once they do, the flag stays `false` on the new rows.
{% endhint %}

### Vendors

The org-wide catalog is `vendors` / `vendor(id)`; `manufacturers` narrows to vendors visible in the current library that can act as manufacturers. A library's own approved list is `libraryVendorList`, each entry carrying an `approvalStatus`. For a paged, sorted, filtered view of that list — with per-vendor component counts — use [`libraryVendors`](#listing-library-vendors-with-paging-and-sorting).

Before creating a vendor, check for near-duplicates:

```graphql
query CheckVendorName {
  vendorSourcing {
    checkVendorNameSimilarity(name: "Texas Instruments") {
      name
      similarity
    }
  }
}
```

#### Listing library vendors with paging and sorting

`libraryVendorList` returns the whole list in one call and is unchanged. Three queries cover the cases where that is not enough — a long list, a sort order, or a status breakdown. All three require the `sourcing.vendor.read` permission in the library named by the `x-library` header (see [RBAC](/advanced-topics/rbac.md)).

| Query                                                                                                            | Returns                                                                                                                                                                                                     |
| ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `libraryVendors(filter: VendorListFilterInput, pagination: VendorListPaginationInput): LibraryVendorConnection!` | The current library's listed vendors, excluding vendors that are archived globally                                                                                                                          |
| `archivedLibraryVendors(pagination: VendorListPaginationInput): LibraryVendorConnection!`                        | The current library's list entries whose global vendor **is** archived. Takes no `filter`                                                                                                                   |
| `libraryVendorStatusCounts: [ApprovalStatusCount!]!`                                                             | How many non-archived listed vendors are in each `approvalStatus`. Always returns all four statuses, zeros included, in the order `PROPOSED`, `IN_REVIEW`, `APPROVED`, `NOT_APPROVED`. It is never filtered |

```graphql
query VendorList($after: String) {
  vendorSourcing {
    libraryVendors(
      filter: { approvalStatus: APPROVED, searchTerm: "Texas" }
      pagination: { first: 25, after: $after, orderBy: { field: TOTAL_COMPONENTS, direction: DESC } }
    ) {
      edges {
        node {
          id
          approvalStatus
          vendor { id name }
          totalComponents
          primarySourceComponents
          prioritizedComponents
          lastEditedAt
        }
      }
      pageInfo { hasNextPage endCursor }
      totalCount
    }
    libraryVendorStatusCounts { status count }
  }
}
```

The archived list takes the same selection under `archivedLibraryVendors(pagination: { first: 25, after: $after })`, with no `filter`.

**Filtering.** `VendorListFilterInput` has two optional fields. `approvalStatus` is an exact match on the entry's status. `searchTerm` is a case-insensitive substring match against the vendor's name or any of its aliases; it is a literal string, so `%` and `_` match themselves rather than acting as wildcards. Both are combined with AND.

**Pagination and sorting.** `VendorListPaginationInput` has three optional fields:

| Field                           | Description                                                                                                                                                      |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `first: Int`                    | Page size. Defaults to 10 when omitted or `null`. Values outside 1–100 are clamped to that range rather than rejected, so `0` or a negative value yields one row |
| `after: String`                 | The `endCursor` of the previous page. Omit it for the first page                                                                                                 |
| `orderBy: VendorListOrderInput` | Sort order. Defaults to `NAME` `ASC`. When supplied, both `field` and `direction` are required                                                                   |

`VendorListOrderField` is one of `NAME`, `APPROVAL_STATUS`, `TOTAL_COMPONENTS`, `PRIORITIZED_COMPONENTS`, `PRIMARY_SOURCE_COMPONENTS` or `LAST_EDITED_AT`; `OrderDirection` is `ASC` or `DESC`. `NAME` sorts case-insensitively, and `APPROVAL_STATUS` follows the workflow order `PROPOSED`, `IN_REVIEW`, `APPROVED`, `NOT_APPROVED` (reversed for `DESC`). Ties are always broken by lowercase vendor name ascending, then vendor id ascending — in both directions — so every sort is a total order and paging never skips or repeats a vendor.

`LibraryVendorConnection` carries `edges { node }`, `pageInfo { hasNextPage endCursor }` and `totalCount`. Edges have no cursor of their own; paging is forward-only, so pass `pageInfo.endCursor` back as `pagination.after` while `hasNextPage` is `true`. `totalCount` is the number of vendors matching the filter, not the page size, and is still returned on an empty page (`edges: []`, `hasNextPage: false`, `endCursor: null`).

{% hint style="warning" %}
Cursors are opaque and are bound to the query and sort that produced them. A cursor that is malformed, empty, from the other list query, or from a different `orderBy` fails with [`VENDOR_LIST_INVALID_CURSOR`](/advanced-topics/error-handling.md#sourcing-query-errors). When you change the sort or switch between `libraryVendors` and `archivedLibraryVendors`, restart from the first page by omitting `after`. Cursors are not checked against the filter or library, so change those from the first page too.
{% endhint %}

#### Component counts on `LibraryVendor`

Every `LibraryVendor` — from the queries above as well as from `libraryVendorList`, `libraryVendor(vendorId)` and the vendor mutations — carries four computed fields describing the vendor's footprint in the current library:

| Field                     | Type        | Description                                                                                                                                                               |
| ------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `totalComponents`         | `Int!`      | Distinct components with a live part the vendor manufactures or a live quote it distributes                                                                               |
| `primarySourceComponents` | `Int!`      | Distinct components where one of the vendor's rows is the **primary** source (rank 1)                                                                                     |
| `prioritizedComponents`   | `Int!`      | Distinct components where one of the vendor's rows is a **prioritized** source (rank 2 or lower)                                                                          |
| `lastEditedAt`            | `DateTime!` | The later of the shared `Vendor` record's `updatedAt` and this library entry's `updatedAt`, to the millisecond. It does not reflect edits to the vendor's parts or quotes |

The counts use the same rule as the `sourcingTag` on [`componentSourcingByVendor`](/core-concepts/components.md#components-sourced-by-a-vendor): a distributor row takes its own quote's rank, and a manufacturer row inherits the best rank among its part's live quotes. Archived parts and quotes are excluded, as are archived and rollup primary-source entries. A vendor with no sourcing in the library reports zero for all three.

`primarySourceComponents` and `prioritizedComponents` are not mutually exclusive — a component can count toward both through different rows — so they do not sum to `totalComponents`.

#### Vendor profile fields

Facts about the vendor itself live once on `Vendor` and are shared by every library:

| Field        | Type            | Description                                                                                              |
| ------------ | --------------- | -------------------------------------------------------------------------------------------------------- |
| `hqAddress`  | `VendorAddress` | Headquarters address — `line1`, `line2`, `city`, `region`, `postalCode`, `country`, all nullable strings |
| `dunsNumber` | `String`        | The vendor's nine-digit D-U-N-S number                                                                   |
| `taxId`      | `String`        | Tax or VAT identifier                                                                                    |

All three are nullable and are accepted on `CreateVendorInput` and `UpdateVendorInput`:

```graphql
mutation UpdateVendorProfile {
  vendorSourcing {
    updateVendor(
      id: "vendor-ti"
      input: {
        hqAddress: {
          line1: "12500 TI Boulevard"
          city: "Dallas"
          region: "TX"
          postalCode: "75243"
          country: "US"
        }
        dunsNumber: "12-345-6789"
        taxId: "12-3456789"
      }
    ) {
      id
      hqAddress {
        line1
        city
        country
      }
      dunsNumber
      taxId
    }
  }
}
```

Inputs are normalized before validation. Empty strings become `null`, string values are trimmed, and nested values are trimmed too. On update, a field you leave out is unchanged and an explicit `null` clears it. A nested object replaces the stored one wholesale; one whose values are all empty is stored as `null`.

| Field                                | Normalization and limits                                                      |
| ------------------------------------ | ----------------------------------------------------------------------------- |
| `dunsNumber`                         | Spaces, dashes, and dots are stripped; the result must be exactly nine digits |
| `taxId`                              | Trimmed; at most 64 characters                                                |
| `hqAddress.line1`, `hqAddress.line2` | Trimmed; at most 255 characters each                                          |
| `hqAddress.city`, `hqAddress.region` | Trimmed; at most 128 characters each                                          |
| `hqAddress.postalCode`               | Trimmed; at most 32 characters                                                |
| `hqAddress.country`                  | Trimmed; at most 64 characters                                                |

A value that fails validation (for example a DUNS that is not nine digits after stripping) is rejected with `BAD_REQUEST` and nothing is written.

{% hint style="info" %}
`dunsNumber` is stored in its stripped form, so `"12-345-6789"` reads back as `"123456789"`. Address values are stored as entered after trimming — no address normalization is applied.
{% endhint %}

#### Library-specific account fields

Facts about *your* relationship with a vendor — your account number and the rep who handles it — differ per customer, so they live on `LibraryVendor` rather than on the shared `Vendor` row. Values set in one library are not visible from another.

| Field            | Type            | Description                                                                                                        |
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------ |
| `accountId`      | `String`        | Your account identifier with the vendor                                                                            |
| `accountManager` | `VendorContact` | The vendor's representative for your account — `name`, `email`, `phone`. This is a contact record, not a Duro user |

Read them from `libraryVendorList`, or for one vendor from `libraryVendor(vendorId)`:

```graphql
query GetLibraryVendorAccounts {
  vendorSourcing {
    libraryVendorList {
      approvalStatus
      accountId
      accountManager {
        name
        email
        phone
      }
      vendor {
        id
        name
      }
    }
  }
}
```

Update them with `updateLibraryVendor`. It takes the vendor's id (not the list entry's id) and writes to the entry in the library named by the `x-library` header. It requires the `sourcing.vendor.update` permission on that library (see [RBAC](/advanced-topics/rbac.md)):

```graphql
mutation UpdateVendorAccount {
  vendorSourcing {
    updateLibraryVendor(
      vendorId: "vendor-digikey"
      input: {
        accountId: "DK-00412"
        accountManager: {
          name: "Jordan Lee"
          email: "jordan.lee@example.com"
          phone: "+1 555 0100"
        }
      }
    ) {
      accountId
      accountManager {
        name
        email
        phone
      }
    }
  }
}
```

`UpdateLibraryVendorInput` normalizes the same way as the vendor profile fields — trimmed, empty strings to `null`, nested object replaced wholesale and stored as `null` when all of its values are empty:

| Field                  | Normalization and limits               |
| ---------------------- | -------------------------------------- |
| `accountId`            | Trimmed; at most 128 characters        |
| `accountManager.name`  | Trimmed; at most 255 characters        |
| `accountManager.email` | Trimmed; must be a valid email address |
| `accountManager.phone` | Trimmed; at most 64 characters         |

An invalid email or an over-length value returns `BAD_REQUEST`. If the vendor is not on the current library's list, the mutation returns `VENDOR_LIST_ENTRY_NOT_FOUND` — add it to the list first. If the vendor does not exist, is archived, or you passed the list entry's id instead of the vendor's id, it returns `VENDOR_NOT_FOUND`.

#### Changing a vendor's approval status

Each list entry's `approvalStatus` is one of `PROPOSED`, `IN_REVIEW`, `APPROVED`, or `NOT_APPROVED`. A vendor added to the list starts as `PROPOSED` unless the caller says otherwise. Move it with `changeVendorApprovalStatus`, which takes the vendor's id (not the list entry's id), locates the entry in the library named by the `x-library` header, and returns the updated `LibraryVendor`:

```graphql
mutation ApproveVendor {
  vendorSourcing {
    changeVendorApprovalStatus(vendorId: "vendor-digikey", status: APPROVED) {
      approvalStatus
      vendor {
        id
        name
      }
    }
  }
}
```

| Argument   | Type              | Description                                         |
| ---------- | ----------------- | --------------------------------------------------- |
| `vendorId` | `ID!`             | The vendor's id — the `vendor.id` on the list entry |
| `status`   | `ApprovalStatus!` | The target status. All four values are accepted     |

Changing a status is a separate permission from editing a vendor. The mutation requires `sourcing.vendor.status_change` in the library named by the `x-library` header; `sourcing.vendor.update` is not enough (see [RBAC](/advanced-topics/rbac.md#sourcing)). The same check applies anywhere else a status can change:

| Operation                                                      | Permission                      |
| -------------------------------------------------------------- | ------------------------------- |
| `changeVendorApprovalStatus`                                   | `sourcing.vendor.status_change` |
| `updateVendorListEntry` (sets the status of an existing entry) | `sourcing.vendor.status_change` |
| `addToVendorList` with `approvalStatus` omitted or `PROPOSED`  | `sourcing.vendor.create`        |
| `addToVendorList` with any other `approvalStatus`              | `sourcing.vendor.status_change` |

A caller without the permission gets `FORBIDDEN` and nothing is written. The mutation only looks up the list entry, so its not-found error differs from `updateLibraryVendor`'s: if the vendor is not on the current library's list — including an unknown vendor id, or a list entry's id passed by mistake — it returns `VENDOR_LIST_ENTRY_NOT_FOUND`, never `VENDOR_NOT_FOUND`. A vendor archived in the registry keeps its list entry, and that entry's status can still be changed.

{% hint style="info" %}
No non-admin system role can change a vendor's status: Editors can add vendors and edit their details, but a proposed vendor stays proposed until someone whose role in that library is an admin role (Site Admin, Admin, or the library's Admin) — or a custom role explicitly granted `sourcing.vendor.status_change` — approves it. An Editor who wants a vendor on the list adds it as `PROPOSED` and hands it off for review.
{% endhint %}

### Primary Sources and Rollup State

```graphql
query GetPrimarySources {
  vendorSourcing {
    primarySources(componentId: "comp-123") {
      id
      rank
      quote {
        dpn
        unitPrice
      }
    }
    rollupPriorityState(componentId: "comp-123") {
      prioritized
      isPrimary
    }
  }
}
```

`rollupPriorityState` reports whether a component's sourcing is prioritized within its assembly rollup and whether it is currently the primary source in that context.

***

## Adding Sourcing

### Creating Parts

`createParts` takes an **array** — batch multiple manufacturers in one call:

```graphql
mutation AddManufacturerPart {
  vendorSourcing {
    createParts(
      inputs: [
        { componentId: "comp-123", mpn: "STM32F407VGT6", manufacturerId: "vendor-st" }
      ]
    ) {
      id
      mpn
    }
  }
}
```

### Creating Quotes

`createQuotes` is also batch. On `CreateQuoteInput`, every field except `partId` is optional:

```graphql
mutation AddDistributorOffers {
  vendorSourcing {
    createQuotes(
      inputs: [
        {
          partId: "part-123"
          distributorId: "vendor-digikey"
          dpn: "497-STM32F407VGT6-ND"
          unitPrice: 8.42
          currency: "USD"
          minQuantity: 1
          leadTimeDays: 14
        }
        {
          partId: "part-123"
          distributorId: "vendor-mouser"
          dpn: "511-STM32F407VGT6"
          unitPrice: 8.15
          currency: "USD"
          minQuantity: 10
          leadTimeDays: 21
        }
      ]
    ) {
      id
      dpn
      unitPrice
    }
  }
}
```

`updatePart` / `updateQuote` edit a single row; `archivePart` / `archiveQuote` remove a row as an active source while `partHistory` / `quoteHistory` retain its record.

{% hint style="info" %}
`updatePart` and `updateQuote` are unconditional writes — no version check, no conflict error. If two callers edit the same row concurrently, the later write silently wins. When overwrite detection matters, use `applySourcingChangeset` with `expectedVersion` instead.
{% endhint %}

***

## Ranking Primary Sources

`createParts`, `createQuotes`, and `setPrimarySource` are independent calls with no cross-call atomicity — if a later step fails (for example `MAX_PRIMARY_SOURCES_REACHED`), earlier steps are not rolled back. When the group needs to succeed or fail together, use [`applySourcingChangeset`](#applying-a-sourcing-changeset).

`setPrimarySource` places a quote in the component's ranked source list; rank 1 is the primary:

```graphql
mutation RankSources {
  vendorSourcing {
    setPrimarySource(input: { componentId: "comp-123", quoteId: "quote-456", rank: 1 }) {
      id
      rank
    }
  }
}
```

* The ranked list has a per-library maximum (3 by default). At the cap, `setPrimarySource` returns `MAX_PRIMARY_SOURCES_REACHED` — un-rank an existing source first.
* `removePrimarySource(id)` takes the `PrimarySource` entry's own `id` (from `primarySources`), not the quote id.
* `reorderPrimarySources` re-ranks the existing list.
* `setRollupAsPrimarySource` adds the assembly rollup at the end of the ranked list, so it becomes the primary only when the list is empty. To add it without making it the primary, pass `belowPrimary: true` in its input; use this for an assembly with no children, whose rollup has no cost. On an empty list the rollup then takes rank 2:
  * Rank 1 stays empty until a quote is set there with `setPrimarySource` and `rank: 1`. Without a rank, the quote is appended after the rollup.
  * While rank 1 is empty, removing a ranked source (`removePrimarySource`) or archiving its quote or part renumbers the list from 1 and moves the rollup to rank 1.
* A spreadsheet import can rank sources too, through its optional Priority column — see [Priority from a spreadsheet import](#priority-from-a-spreadsheet-import).

### Priority from a spreadsheet import

A [component import](/core-concepts/importing-components.md#sourcing-columns-src-targets) can say which of a component's sources is the primary and which are prioritized, instead of leaving that to be set by hand afterwards. The sheet's optional **Priority** column takes one of three values per row:

| Cell      | Requests                                                     |
| --------- | ------------------------------------------------------------ |
| `Primary` | Rank 1 — the component's primary source                      |
| `Yes`     | A prioritized source below the primary (rank 2 or lower)     |
| blank     | No priority. The import neither ranks nor un-ranks the quote |

The column writes to the same ranked list as `setPrimarySource`, so the per-library maximum above (3 by default) applies to what a sheet requests as well.

Priority is applied last. It runs after every sourcing row of the job has landed and after the single-quote auto-assignment has run. A row whose sourcing failed and produced no quote is excluded from priority processing altogether.

#### Priority on the import result

Each sourcing result row (`ImportSourcingResultOutput`) carries two nullable fields for this. They are fields of the import result itself, separate from its part and quote fields:

| Field             | Type                      | Meaning                                                        |
| ----------------- | ------------------------- | -------------------------------------------------------------- |
| `priority`        | `String`                  | The cell as written, or `null` when the row stated no priority |
| `priorityOutcome` | `SourcingPriorityOutcome` | What priority processing did with the row, or `null`           |

`SourcingPriorityOutcome` has four values:

| Outcome     | Meaning                                          |
| ----------- | ------------------------------------------------ |
| `PRIMARY`   | The quote was set as the primary source (rank 1) |
| `PRIORITY`  | The quote was added as a prioritized source      |
| `UNCHANGED` | The quote was already prioritized; nothing moved |
| `REFUSED`   | The priority was not applied                     |

`REFUSED` means the request could not be honored at apply time — for example the component had already reached its maximum number of prioritized sources, or it already had a different primary source. The outcome contract also describes refusal for a row with no quote, but such rows are excluded from priority processing, so do not rely on a non-null outcome for a failed row.

{% hint style="info" %}
**`priorityOutcome` is null in two situations.** Either the row stated no priority, or a priority was stated but its outcome was not recorded. Use `priority` to tell them apart: when `priority` is set and `priorityOutcome` is `null`, the outcome is unrecorded, not refused — priority may still have been applied, so check the component's [prioritized sources](#primary-sources-and-rollup-state) rather than assuming nothing happened.
{% endhint %}

***

## Applying a Sourcing Changeset

`applySourcingChangeset` stages a whole set of part/quote adds, edits, deletes, and re-ranks as a single **atomic** operation — the same mechanism the Duro app uses for its sourcing edit sessions. It returns `Boolean!` — `true` on success — and a changeset that fails validation leaves the component's sourcing unchanged.

```graphql
mutation MultiSourceComponent {
  vendorSourcing {
    applySourcingChangeset(
      componentId: "comp-123"
      changeset: {
        newParts: [
          { tempId: "p1", mpn: "STM32F407VGT6", manufacturerId: "vendor-st" }
        ]
        newQuotes: [
          {
            tempId: "q1"
            partRef: "p1"
            distributorId: "vendor-digikey"
            dpn: "497-STM32F407VGT6-ND"
            unitPrice: 8.42
            currency: "USD"
          }
        ]
        editedParts: []
        editedQuotes: []
        deletedPartIds: []
        deletedQuoteIds: []
        prioritizedOrder: ["q1"]
        primary: { kind: QUOTE, quoteRef: "q1" }
      }
    )
  }
}
```

Two patterns to know:

* **`tempId` / `Ref`** — reference a row created in the *same* call before it has a real id: `newQuotes[0].partRef` points at `newParts[0].tempId`, and `prioritizedOrder` can reference new quotes' `tempId`s. `primary.kind` is `QUOTE` or `PART` — a manufacturer part itself can be the primary when no distributor offer is ranked.
* **`expectedVersion`** — when editing an existing part or quote, pass the `version` you last read. If it no longer matches at flush time, the whole changeset is rejected (`PART_VERSION_CONFLICT` / `QUOTE_VERSION_CONFLICT`) instead of silently overwriting a concurrent change.

### Save IDs and sourcing snapshots

Applying a changeset photographs the component's sourcing — its parts, quotes, and primary-source ranking — into the component's history as a **`sourcingSnapshot`**. Read it back through `Component.history`; each row also exposes `effectiveSourcingSnapshot`, the sourcing in effect at that row even when the row itself did not touch sourcing. See [Component History](/core-concepts/components.md#component-history).

When a sourcing changeset is one part of a larger save — the Duro app's component page save, or a connector publishing a part and its MPNs together — pass the same optional **`saveId`** on the changeset and on the `component.update` / `item.update` that belongs to it. The writes are then recorded as one save: a single history row carrying both the component's field changes and the sourcing photo, rather than one row per call.

```graphql
mutation SaveSourcing($saveId: ID!) {
  vendorSourcing {
    applySourcingChangeset(
      componentId: "comp-123"
      changeset: {
        newParts: [{ tempId: "p1", mpn: "STM32F407VGT6", manufacturerId: "vendor-st" }]
        newQuotes: []
        editedParts: []
        editedQuotes: []
        deletedPartIds: []
        deletedQuoteIds: []
        prioritizedOrder: []
        primary: { kind: PART, partRef: "p1" }
        saveId: $saveId
      }
    )
  }
}
```

Use a fresh UUID per save. Omit `saveId` when the changeset stands alone — it is then its own save and writes its own row. `saveId` groups history only; it does not make the changeset and the component update a single transaction, so a rejected changeset does not roll back a component update that shared its `saveId`.

***

## Documents on Sourcing

Documents attach at either level: to a part (e.g. a manufacturer datasheet) via `linkDocumentToPart`, or to a quote (e.g. a distributor-specific document) via `linkDocumentToQuote`.

```graphql
mutation AttachDatasheet {
  vendorSourcing {
    linkDocumentToPart(input: { documentId: "doc-789", partId: "part-123" }) {
      id
    }
  }
}
```

`documentsByComponent(componentId)` returns every sourcing document on a component regardless of level — each result carries `linkType` (`PART` or `QUOTE`).

***

## Error Codes

Sourcing errors are `GraphQLError`s with a stable `code` in `extensions` (see [Error Handling](/advanced-topics/error-handling.md)):

| Code                                               | Meaning                                                                                                                                                                                                                 |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DUPLICATE_PART`                                   | A part already exists for that manufacturer + MPN                                                                                                                                                                       |
| `DUPLICATE_QUOTE`                                  | A quote already exists for that part + distributor + DPN + `minQuantity`                                                                                                                                                |
| `PART_VERSION_CONFLICT` / `QUOTE_VERSION_CONFLICT` | `expectedVersion` mismatch — re-read and retry                                                                                                                                                                          |
| `MAX_PRIMARY_SOURCES_REACHED`                      | The ranked-source list is at its per-library cap                                                                                                                                                                        |
| `INVALID_QUANTITY_RANGE`                           | A quote's `minQuantity` exceeds its `maxQuantity`                                                                                                                                                                       |
| `VENDOR_NOT_FOUND`                                 | The vendor does not exist or is archived — for `updateLibraryVendor`, also returned when given a list entry's id instead of the vendor's id                                                                             |
| `VENDOR_LIST_ENTRY_NOT_FOUND`                      | `updateLibraryVendor` or `changeVendorApprovalStatus` was called for a vendor that is not on the current library's list — for `changeVendorApprovalStatus`, also returned for an unknown vendor id or a list entry's id |
| `BAD_REQUEST`                                      | An input failed validation — for example a DUNS that is not nine digits or an invalid `accountManager.email`                                                                                                            |
| `VENDOR_LIST_INVALID_CURSOR`                       | The `pagination.after` passed to `libraryVendors` or `archivedLibraryVendors` is not a valid cursor for that query and sort — see [Sourcing Query Errors](/advanced-topics/error-handling.md#sourcing-query-errors)     |

## Next Steps

* Evaluate sourcing changes as part of a formal review with [Change Orders](/core-concepts/change-orders.md)
* Attach datasheets and certifications with [Documents](/core-concepts/documents.md)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.durohub.com/core-concepts/sourcing.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
