# Welcome to Duro Dev Center

<figure><img src="/files/N1nuZt9TUIc72NLwoCr7" alt=""><figcaption></figcaption></figure>

Welcome to the official documentation for the Duro Platform. This documentation will help you integrate Duro's powerful product lifecycle management capabilities into your applications.

### What is Duro?

Duro is a modern, highly configurable PLM platform that helps hardware teams manage their product data, collaborate on designs, and streamline their development process.

### What can you build?

With the Duro API, you can:

* Manage components and sophisticated BOM assembly structures
* Manage role based access controls for your organizations and libraries
* Search part data with a powerful filtering engine
* Access and update technical documentation
* Automate custom change management workflows
* Create custom integrations with your existing tools
* Build automated reporting and analytics

### Getting Started

The fastest way to get started with the Duro API is to:

1. [Create a Duro account](https://durohub.com)
2. [Get authenticated](/getting-started/authentication)
3. Try our [Hello World](/getting-started/hello-world) example

### API Endpoints

Our main GraphQL API explorer is available at:

```
https://api.durohub.com/graphql
```

### Need Help?

* Join our [Developer Community](/community/developer-community)
* Check our [Error Handling](/advanced-topics/error-handling) guide
* Contact support at <developers@durohub.com>

{% hint style="info" %}
This documentation is continuously updated. Make sure to check back regularly for the latest information.
{% endhint %}


# Introduction

The Duro GraphQL API provides a powerful interface to interact with your product lifecycle management data. This guide will help you understand the core concepts and get started with integration.

### Why GraphQL?

Our API is built using GraphQL, offering several advantages:

* Request exactly the data you need and ability to fetch multiple resources in parallel
* Strong typing and schema validation
* Efficient data loading
* Interactive documentation and exploration

### API Architecture

The Duro API follows these core principles:

* RESTful-like resource patterns
* Clear name-spaced resources
* Consistent error handling
* Rate limiting for stability
* Versioned schema updates

### Prerequisites

Before you begin, you'll need:

* A Duro account
* Basic understanding of GraphQL
* Your preferred GraphQL client

### Tools and SDKs

We recommend using these tools:

* [GraphQL Playground](https://api.durohub.com/graphql)
* [Apollo Client](https://www.apollographql.com/docs/react/)
* [Postman](https://www.postman.com/) (with GraphQL support)

### Next Steps

Learn how to [authenticate your requests](/getting-started/authentication) to get started.


# Authentication

The Duro GraphQL API uses Bearer token and API key (via `x-api-key` header) authentication. All API requests must include an authorization token in the header.

### Obtaining an API Authentication Token

To get started with the Duro API, you'll need to:

1. Create a Duro account at [durohub.com](https://durohub.com)
2. Navigate to your account settings
3. Generate an API token from the Developer section
4. Your URL to get an API key is: `https://durohub.com/org/@<your org slug>/libs/<your library slug>/settings/api-keys`

![API Key Created Window](/files/ToqWcFiFQ4l30WbBRvrI)

### Using the Token

Include your API token in all requests using the `x-api-key` header:

```graphql
x-api-key: YOUR_API_TOKEN
```

### Token Security

* Never share your API tokens
* Rotate tokens regularly
* Use different tokens for development and production
* Store tokens securely in environment variables

{% hint style="warning" %}
Never commit API tokens to version control or expose them in client-side code.
{% endhint %}

### Checking the Authenticated User

To confirm a token is valid and inspect the current identity, query `me` under the `user` namespace:

```graphql
query {
  user {
    me {
      hasOrganizations
    }
  }
}
```

`hasOrganizations` is a non-null `Boolean` that returns `true` when the authenticated user belongs to at least one organization. It is useful in onboarding flows to decide whether to prompt the user to create or join an organization before making organization-scoped calls.

See [Current User](/getting-started/current-user) for the full set of fields `me` returns.

### Next Steps

Learn how to make your first API call in the [Hello World](/getting-started/hello-world) guide.


# API v2 Migration Guide

This guide provides comprehensive information for migrating your existing Duro API integrations to API v2. This is a **breaking change** release that introduces a new header-based context system and role-based access control (RBAC).

## Overview

### Why This Change?

The previous API suffered from **inconsistent context handling**:

* Some operations required `libraryId` as a direct parameter
* Others expected it within an `input` object
* Some used `library` (slug) while others used `libraryId` (UUID)
* Organization context was sometimes explicit, sometimes implicit

**API v2 standardizes context through HTTP headers**, eliminating redundancy and providing a cleaner, more predictable interface.

### What's New?

| Feature              | v1 (Legacy)                  | v2 (New)                      |
| -------------------- | ---------------------------- | ----------------------------- |
| Library Context      | Parameter in each operation  | `x-library` header            |
| Organization Context | Parameter in some operations | `x-organization` header       |
| Access Control       | Simple role enum             | Full RBAC with permissions    |
| API Key Scope        | Tied to library at creation  | Must match `x-library` header |

### Key Benefits

1. **Consistency**: Every operation uses the same context mechanism
2. **Cleaner Queries**: No redundant library/organization IDs in every request
3. **Fine-grained Permissions**: New RBAC system enables granular access control
4. **Better Security**: Permission checks on every request
5. **Simplified Client Code**: Set headers once, use everywhere

***

## Migration Checklist

Before starting your migration:

* [ ] Identify all API calls in your codebase
* [ ] Update your HTTP client to send required headers
* [ ] Review the [Breaking Changes](#breaking-changes) section
* [ ] Update input objects to remove deprecated fields
* [ ] Test with API v2 endpoint
* [ ] Update error handling for new permission errors

***

## Required Headers

All authenticated API requests **must** include these headers:

### `x-organization` (Required)

Identifies the organization context. Accepts either:

* Organization UUID: `550e8400-e29b-41d4-a716-446655440000`
* Organization slug: `@my-company`

```http
x-organization: @my-company
```

### `x-library` (Required for library-scoped operations)

Identifies the library context. Accepts either:

* Library UUID: `660e8400-e29b-41d4-a716-446655440000`
* Library slug path: `@my-company/product-library`

```http
x-library: @my-company/product-library
```

### `x-api-key` (For API authentication)

Your API key for authentication (unchanged from v1).

```http
x-api-key: your-api-key-here
```

{% hint style="warning" %}
**Important**: Your API key is scoped to a specific library. The `x-library` header **must match** the library your API key was created for. Mismatched headers will result in `403 Forbidden` errors.
{% endhint %}

### Complete Request Example

```bash
curl -X POST https://api.durohub.com/graphql \
  -H "Content-Type: application/json" \
  -H "x-api-key: your-api-key" \
  -H "x-organization: @acme-corp" \
  -H "x-library: @acme-corp/hardware-library" \
  -d '{"query": "{ component { findAll { edges { node { id name } } } } }"}'
```

***

## Complete Breaking Changes Reference

This section provides **exhaustive tables** of every change grouped by category.

***

### 1. Input Objects: Removed Fields

Fields removed from input types - context now provided via headers.

| Input Type                         | Removed Field(s)                             | Header to Use    |
| ---------------------------------- | -------------------------------------------- | ---------------- |
| `BulkCreateReusableInstancesInput` | `libraryId: String!`                         | `x-library`      |
| `CategoryFilterInput`              | `libraryId: String`                          | `x-library`      |
| `ComponentFilterInput`             | `libraryId: String`, `libraryIds: [String!]` | `x-library`      |
| `ConfigFilterInput`                | `libraryId: String`                          | `x-library`      |
| `ConfigureLibraryInput`            | `libraryId: String!`                         | `x-library`      |
| `CreateApiKeyInput`                | `libraryId: String!`                         | `x-library`      |
| `CreateComponentInput`             | `libraryId: ID!`                             | `x-library`      |
| `CreateLibraryInput`               | `organizationId: String!`                    | `x-organization` |
| `CreateValidationRuleInput`        | `libraryId: ID!`                             | `x-library`      |
| `CreateWebhookInput`               | `libraryId: String!`                         | `x-library`      |
| `CustomStatusFilterInput`          | `libraryId: String`                          | `x-library`      |
| `DeactivateApiKeyInput`            | `libraryId: String!`                         | `x-library`      |
| `FindAllApiKeyForUserInput`        | `libraryId: String`                          | `x-library`      |
| `FindOneApiKeyForUserInput`        | `libraryId: String`                          | `x-library`      |
| `IdsFilterInput`                   | `libraryId: String`                          | `x-library`      |
| `IsPinnedComponentInput`           | `libraryId: ID!`                             | `x-library`      |
| `NextRevisionInput`                | `libraryId: String!`                         | `x-library`      |
| `PinComponentsInput`               | `libraryId: ID!`                             | `x-library`      |
| `RevokeAllApiKeysForUserInput`     | `libraryId: String!`                         | `x-library`      |
| `RotateApiKeyInput`                | `libraryId: String!`                         | `x-library`      |
| `SetLibraryConfigInput`            | `libraryId: String!`                         | `x-library`      |
| `UnPinComponentsInput`             | `libraryId: ID!`                             | `x-library`      |
| `UpdateApiKeyInput`                | `libraryId: String!`                         | `x-library`      |
| `UpdateComponentInput`             | `libraryId: ID`                              | `x-library`      |
| `ValidateApiKeyInput`              | `libraryId: String!`                         | `x-library`      |
| `ValidateRevisionValueInput`       | `libraryId: String!`                         | `x-library`      |
| `ValidationRuleFilter`             | `libraryId: ID`                              | `x-library`      |
| `NaturalLanguageSearchInput`       | `libraryIds: [String!]`                      | `x-library`      |

***

### 2. Input Objects: Modified Fields

Fields changed or deprecated in input types.

| Input Type        | Field          | v1 (Legacy)                              | v2 (New)                                            |
| ----------------- | -------------- | ---------------------------------------- | --------------------------------------------------- |
| `BulkInviteInput` | `role`         | `role: OrganizationRoleEnum!` (required) | `role: OrganizationRoleEnum` (optional, deprecated) |
| `BulkInviteInput` | `roleId`       | *(not present)*                          | `roleId: String` (deprecated, use `orgRoleId`)      |
| `BulkInviteInput` | `orgRoleId`    | *(not present)*                          | `orgRoleId: String` (new, preferred)                |
| `BulkInviteInput` | `libraryRoles` | *(not present)*                          | `libraryRoles: [LibraryRoleAssignment!]` (new)      |

***

### 3. Root Query Changes

Changes to top-level Query fields.

| Query               | v1 Signature                                              | v2 Signature                                      | Change Type              |
| ------------------- | --------------------------------------------------------- | ------------------------------------------------- | ------------------------ |
| `altiumHealthCheck` | `altiumHealthCheck: AltiumHealthCheckResponse!`           | *(removed)*                                       | **REMOVED**              |
| `customStatuses`    | `customStatuses(filter: CustomStatusFilterInput!)`        | `customStatuses(filter: CustomStatusFilterInput)` | Filter now optional      |
| `configs`           | `configs(filter: ConfigFilterInput!)`                     | `configs(filter: ConfigFilterInput)`              | Filter now optional      |
| `configSyncStatus`  | `configSyncStatus(libraryId: String!, type: ConfigType!)` | `configSyncStatus(type: ConfigType!)`             | `libraryId` removed      |
| `libraries`         | `libraries(organizationId: ID!)`                          | `libraries`                                       | `organizationId` removed |
| `roles`             | *(not present)*                                           | `roles: RoleQueryNamespace!`                      | **NEW**                  |

***

### 4. Root Mutation Changes

Changes to top-level Mutation fields.

| Mutation                  | v1 Signature                                              | v2 Signature                    | Change Type                        |
| ------------------------- | --------------------------------------------------------- | ------------------------------- | ---------------------------------- |
| `generateAltiumViewerUrl` | `generateAltiumViewerUrl(input: GenerateViewerUrlInput!)` | *(removed)*                     | **REMOVED**                        |
| `membership`              | `membership: MembershipMutationNamespace!`                | *(removed from root)*           | Moved to `organization.membership` |
| `roles`                   | *(not present)*                                           | `roles: RoleMutationNamespace!` | **NEW**                            |

***

### 5. Namespace Query Method Changes

Changes to methods within query namespaces.

#### `CategoryQueryNamespace`

| Method    | v1 Signature                                           | v2 Signature                           |
| --------- | ------------------------------------------------------ | -------------------------------------- |
| `findAll` | `findAll(libraryId: ID!, filter: CategoryFilterInput)` | `findAll(filter: CategoryFilterInput)` |

#### `ChangeOrdersQueryOperations`

| Method         | v1 Signature                                                                        | v2 Signature                                                       |
| -------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `get`          | `get(library: String, filter: ChangeOrderFilterInput, pagination: PaginationInput)` | `get(filter: ChangeOrderFilterInput, pagination: PaginationInput)` |
| `getTemplates` | `getTemplates(library: String)`                                                     | `getTemplates`                                                     |

#### `ComponentQueryNamespace`

| Method                   | v1 Signature                                                                                    | v2 Signature                                                                    |
| ------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `findOne`                | `findOne(id: ID!): Component!`                                                                  | `findOne(id: ID!): Component` (now nullable)                                    |
| `validateAttributes`     | `validateAttributes(categoryId: ID!, attributeValues: [AttributeValueInput!]!, libraryId: ID!)` | `validateAttributes(categoryId: ID!, attributeValues: [AttributeValueInput!]!)` |
| `getAllPinnedComponents` | `getAllPinnedComponents(libraryId: String!)`                                                    | `getAllPinnedComponents`                                                        |

#### `IdentifierQueryNamespace`

| Method                     | v1 Signature                                                                                                       | v2 Signature                                                                                   |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| `validateOverrides`        | `validateOverrides(libraryId: String!, overrides: [IdElementOverrideInput!]!)`                                     | `validateOverrides(overrides: [IdElementOverrideInput!]!)`                                     |
| `validateCompleteOverride` | `validateCompleteOverride(libraryId: String!, overrides: [IdElementOverrideInput!]!, componentId: String)`         | `validateCompleteOverride(overrides: [IdElementOverrideInput!]!, componentId: String)`         |
| `overridePermissions`      | `overridePermissions(libraryId: String!)`                                                                          | `overridePermissions`                                                                          |
| `reusableGroupInstances`   | `reusableGroupInstances(libraryId: String!, configId: String!, groupName: String!, filters: [ElementValueInput!])` | `reusableGroupInstances(configId: String!, groupName: String!, filters: [ElementValueInput!])` |
| `validateFreeformOverride` | `validateFreeformOverride(libraryId: String!, value: String!, componentId: String)`                                | `validateFreeformOverride(value: String!, componentId: String)`                                |

#### `OrganizationQueryNamespace`

| Method             | v1 Signature                                                             | v2 Signature                                                 |
| ------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------ |
| `invitations`      | `invitations(filter: InvitationFilterInput): [OrganizationInvitation!]!` | `invitations(filter: InvitationFilterInput): [Invitation!]!` |
| `discoverByDomain` | *(not present)*                                                          | `discoverByDomain: Organization`                             |

#### `WebhookQueryNamespace`

| Method    | v1 Signature                         | v2 Signature |
| --------- | ------------------------------------ | ------------ |
| `findAll` | `findAll(input: FindWebhooksInput!)` | `findAll`    |

***

### 6. Namespace Mutation Method Changes

Changes to methods within mutation namespaces.

#### `ChangeOrdersMutationOperations`

| Method           | v1 Signature                                                   | v2 Signature                                  |
| ---------------- | -------------------------------------------------------------- | --------------------------------------------- |
| `create`         | `create(input: CreateChangeOrderInput!, libraryId: ID)`        | `create(input: CreateChangeOrderInput!)`      |
| `createTemplate` | `createTemplate(input: CreateTemplateInput!, library: String)` | `createTemplate(input: CreateTemplateInput!)` |

#### `ConfigMutationNamespace`

| Method                            | v1 Signature                                          | v2 Signature                      |
| --------------------------------- | ----------------------------------------------------- | --------------------------------- |
| `manuallyProcessCategoriesConfig` | `manuallyProcessCategoriesConfig(libraryId: String!)` | `manuallyProcessCategoriesConfig` |

#### `OrganizationMutationNamespace`

| Method              | v1 Signature                                                        | v2 Signature                               |
| ------------------- | ------------------------------------------------------------------- | ------------------------------------------ |
| `membership`        | `membership(organizationId: String!): MembershipMutationNamespace!` | `membership: MembershipMutationNamespace!` |
| `declineInvitation` | *(not present)*                                                     | `declineInvitation(id: String!): Boolean!` |

#### `MembershipMutationNamespace`

| Method                | v1 Signature                                           | v2 Signature                                                      |
| --------------------- | ------------------------------------------------------ | ----------------------------------------------------------------- |
| `update`              | `update(userId: String!, role: OrganizationRoleEnum!)` | `update(userId: String!, roleId: String!)`                        |
| `updateLibraryAccess` | *(not present)*                                        | `updateLibraryAccess(input: UpdateLibraryAccessInput!): Boolean!` |
| `removeOrgRole`       | *(not present)*                                        | `removeOrgRole(userId: String!): Boolean!`                        |

***

### 7. Enum Changes

#### `OrganizationRoleEnum`

| v1 Values       | v2 Values    | Notes                      |
| --------------- | ------------ | -------------------------- |
| `SITE_ADMIN`    | `SITE_ADMIN` | Unchanged                  |
| `ADMIN`         | `ADMIN`      | Unchanged                  |
| `USER`          | `EDITOR`     | **RENAMED**                |
| `APPROVER`      | *(removed)*  | **REMOVED** - use `EDITOR` |
| `REVIEWER`      | `REVIEWER`   | Unchanged                  |
| *(not present)* | `VIEWER`     | **NEW**                    |
| `SUPPLIER`      | `SUPPLIER`   | Unchanged                  |

***

### 8. Type Changes

#### Renamed Types

| v1 Type                  | v2 Type      | Notes                     |
| ------------------------ | ------------ | ------------------------- |
| `OrganizationInvitation` | `Invitation` | Enhanced with RBAC fields |

#### Modified Types

| Type               | Change                                                                                     |
| ------------------ | ------------------------------------------------------------------------------------------ |
| `Organization`     | Added `roles: [Role!]!` field                                                              |
| `Organization`     | Changed `invitations` return type to `[Invitation!]!`                                      |
| `OrganizationRole` | Added `rbacRole: Role`, `hasDirectOrgRole: Boolean!`, `libraryAccess: [LibraryAccessDto!]` |
| `Library`          | Added `stats: LibraryStatsType` field                                                      |

***

### 9. Removed Types (Complete List)

| Removed Type                | Category                    |
| --------------------------- | --------------------------- |
| `AltiumHealthCheckResponse` | Altium integration          |
| `ViewerUrlResponse`         | Altium integration          |
| `GenerateViewerUrlInput`    | Altium integration          |
| `OrganizationInvitation`    | Renamed to `Invitation`     |
| `FindWebhooksInput`         | Webhooks (no longer needed) |

***

### 10. New Types (Complete List)

| New Type                   | Category    | Purpose                                |
| -------------------------- | ----------- | -------------------------------------- |
| `Role`                     | RBAC        | Defines a role with permissions        |
| `Permission`               | RBAC        | Individual permission definition       |
| `RoleType`                 | RBAC        | Enum: `ORG`, `LIBRARY`                 |
| `RoleMutationNamespace`    | RBAC        | Mutations for role management          |
| `RoleQueryNamespace`       | RBAC        | Queries for role management            |
| `CreateRoleInput`          | RBAC        | Input for creating custom roles        |
| `UpdateRoleInput`          | RBAC        | Input for updating roles               |
| `DeleteRoleInput`          | RBAC        | Input for deleting roles               |
| `Invitation`               | Invitations | Replaces `OrganizationInvitation`      |
| `LibraryAccessDto`         | RBAC        | Library-specific role info             |
| `LibraryRoleAssignment`    | RBAC        | Input for assigning library roles      |
| `LibraryStatsType`         | Library     | Component/user counts                  |
| `UpdateLibraryAccessInput` | RBAC        | Input for updating user library access |

***

### 11. Code Examples (Before/After)

Quick reference examples for common operations.

#### Components

```graphql
# v1 - Creating a component
mutation {
  component {
    create(inputs: [{ name: "Widget", libraryId: "lib-uuid", categoryId: "cat-uuid" }]) {
      id
    }
  }
}

# v2 - libraryId removed from input, use x-library header
mutation {
  component {
    create(inputs: [{ name: "Widget", categoryId: "cat-uuid" }]) {
      id
    }
  }
}
```

#### Change Orders

```graphql
# v1 - Querying change orders
query {
  changeOrders {
    get(library: "@company/lib", filter: { status: [OPEN] }) {
      connection { edges { node { id } } }
    }
  }
}

# v2 - library parameter removed
query {
  changeOrders {
    get(filter: { status: [OPEN] }) {
      connection { edges { node { id } } }
    }
  }
}
```

#### Libraries

```graphql
# v1 - Listing libraries
query {
  libraries(organizationId: "org-uuid") {
    id
    name
  }
}

# v2 - organizationId removed, use x-organization header
query {
  libraries {
    id
    name
  }
}
```

#### Membership

```graphql
# v1 - Updating user role
mutation {
  organization {
    membership(organizationId: "org-uuid") {
      update(userId: "user-uuid", role: ADMIN) { id }
    }
  }
}

# v2 - organizationId removed, role changed to roleId
mutation {
  organization {
    membership {
      update(userId: "user-uuid", roleId: "role-uuid") { id }
    }
  }
}
```

***

## Role-Based Access Control (RBAC)

API v2 introduces a comprehensive RBAC system that replaces the simple role enum approach.

### Key Concepts

1. **Permissions**: Granular capabilities like `components.create`, `change_orders.approve`
2. **Roles**: Collections of permissions (e.g., Editor, Viewer, Admin)
3. **Role Types**: Organization-level (`ORG`) or Library-level (`LIBRARY`)
4. **Hierarchical Resolution**: Library roles override organization roles for specific libraries

### Role Hierarchy

| Level             | Description                                      |
| ----------------- | ------------------------------------------------ |
| Organization Role | Default permissions for all libraries in the org |
| Library Role      | Override permissions for a specific library      |

**Example**: A user with Org Editor role but Library Viewer role for "Engineering Library" will have:

* Editor permissions in all other libraries
* Viewer (read-only) permissions in Engineering Library

### Legacy Role Mapping

If you were using the legacy `OrganizationRoleEnum`, here's how roles map:

| Legacy Enum  | New System Role | Permissions                         |
| ------------ | --------------- | ----------------------------------- |
| `SITE_ADMIN` | Site Admin      | Full system access (admin)          |
| `ADMIN`      | Admin           | Full organization access (admin)    |
| `USER`       | **Editor**      | Create, edit, approve change orders |
| `APPROVER`   | *Removed*       | Use Editor or Reviewer              |
| `REVIEWER`   | **Viewer**      | Read-only access                    |
| `SUPPLIER`   | Supplier        | Limited external access             |

{% hint style="info" %}
The `USER` role has been renamed to `EDITOR` to better reflect its capabilities. The `APPROVER` role has been removed - use `EDITOR` for users who need to approve change orders.
{% endhint %}

### New RBAC Queries

```graphql
# Get all available permissions
query {
  roles {
    allPermissions {
      id
      name
      description
      resource
      action
    }
  }
}

# Get a role by ID
query {
  roles {
    findById(id: "role-uuid") {
      id
      name
      description
      type
      isAdmin
      isSystemRole
      permissions {
        id
        name
      }
    }
  }
}
```

### Permission Categories

| Resource        | Actions                                                                                             |
| --------------- | --------------------------------------------------------------------------------------------------- |
| `components`    | `create`, `read`, `update`, `delete`, `revision.create`                                             |
| `assemblies`    | `create`, `read`, `update`, `delete`                                                                |
| `change_orders` | `create`, `read`, `update`, `delete`, `submit`, `approve`, `reject`, `release`, `withdraw`, `reset` |
| `library`       | `read`, `settings.update`, `categories.manage`, `statuses.manage`, `features.manage`                |
| `organization`  | `read`, `settings.update`, `users.read`, `users.invite`, `users.remove`, `users.update_role`        |
| `roles`         | `read`, `create`, `update`, `delete`, `assign`                                                      |
| `comments`      | `create`, `read`, `update`, `delete`, `moderate`                                                    |

### Permission Errors

API v2 will return `403 Forbidden` with detailed error codes when permission checks fail:

```json
{
  "errors": [
    {
      "message": "You do not have permission to perform this action",
      "extensions": {
        "code": "FORBIDDEN",
        "requiredPermission": "components.create"
      }
    }
  ]
}
```

{% hint style="info" %}
**Permission checks run server-side — there is no public query to check permissions yourself.** The "permission checks on every request" behavior means Duro evaluates the caller's effective role and enforces access automatically; it does not expose a client-callable permission-check query on the public API. The internal `checkPermission` operation is not part of the public federated schema. To determine access from your integration, query a user's assigned roles and permissions (see [Role-Based Access Control](/advanced-topics/rbac)) or handle the `FORBIDDEN` error above.
{% endhint %}

***

## Invitation System Changes

### Type Rename

The `OrganizationInvitation` type has been renamed to `Invitation` and enhanced with RBAC support:

```graphql
# v2 Invitation type
type Invitation {
  id: String!
  email: String!
  roleId: String!        # New: references RBAC role
  role: Role!            # New: full role details
  invitedById: String!
  invitedBy: User!
  accepted: Boolean!
  organization: Organization
}
```

### Bulk Invite Changes

```graphql
# v1 (Legacy)
mutation {
  organization {
    bulkInvite(input: {
      emails: ["user@example.com"]
      organizationId: "org-uuid"
      role: ADMIN
    }) {
      successful { email }
    }
  }
}

# v2 (New) - role is deprecated, use orgRoleId or libraryRoles
mutation {
  organization {
    bulkInvite(input: {
      emails: ["user@example.com"]
      organizationId: "org-uuid"
      orgRoleId: "editor-role-uuid"
      libraryRoles: [
        { libraryId: "lib-uuid", roleName: "Viewer" }
      ]
    }) {
      successful { email }
    }
  }
}
```

***

## Sandbox Organizations

Sandbox organizations are full organizations of `type = SANDBOX` that inherit their parent organization's plan and entitlements. They give admins a real org to experiment in — practice SSO setup, model libraries differently, run demos or training — without touching production. A sandbox behaves like any other organization (its own libraries, members, and features) but is created as a child of an existing **real (non-sandbox)** organization and does **not** consume an organization grant.

{% hint style="info" %}
These fields are **additive** — they do not change any existing v2 behavior. Whether the API surfaces sandbox creation depends on your subscription's `sandbox_orgs` entitlement.
{% endhint %}

### GraphQL surface

```graphql
# Organization gains a sandbox flag — true when you are operating inside a sandbox
type Organization {
  # ...existing fields
  isSandbox: Boolean!
}

# The signed-in user's namespace reports what they may create
type UserQueryNamespace {
  # ...existing fields
  orgCreationOptions: OrgCreationOptions!
}

# Whether the user may create a production org (holds a pending grant) and/or a
# sandbox org (Site Admin of a real org with sandbox headroom), plus the parent
# organizations eligible to host a sandbox. Introspect the exact field set in the
# GraphQL Playground.
type OrgCreationOptions { ... }

# A real organization the user may create a sandbox under, with its sandbox_orgs
# limit and current usage.
type SandboxParentOption { ... }

# Point at a real parent org to create a sandbox; omit to create a production org
input CreateOrganizationInput {
  # ...existing fields
  parentOrganizationId: ID
}

# Archiving a sandbox frees its slot against the parent's sandbox_orgs quota
type OrganizationMutationNamespace {
  # ...existing methods
  archiveSandboxOrganization(id: ID!): Organization!
}
```

### Deciding what to create

Query `orgCreationOptions` to learn whether the current user can create a production organization, a sandbox, or both, and which parent organizations still have sandbox headroom:

```graphql
query {
  user {
    orgCreationOptions {
      canCreateProduction
      canCreateSandbox
      sandboxParents {
        organization { id name }
        sandboxLimit
        sandboxUsed
        sandboxRemaining
      }
    }
  }
}
```

{% hint style="info" %}
`OrgCreationOptions` and `SandboxParentOption` are new types; the selection above is representative. Use the [GraphQL Playground](https://api.durohub.com/graphql) as the source of truth for their exact fields.
{% endhint %}

### Creating a sandbox

Pass `parentOrganizationId` when creating an organization to make it a sandbox child of that parent. The parent must be a real organization you are Site Admin of, and it must have remaining `sandbox_orgs` headroom — otherwise the request fails with `SANDBOX_PARENT_FORBIDDEN` (see [Error Handling](/advanced-topics/error-handling#sandbox-organization-errors)).

```graphql
mutation {
  organization {
    create(input: {
      name: "Acme Sandbox"
      parentOrganizationId: "parent-org-uuid"
    }) {
      id
      isSandbox
    }
  }
}
```

### Archiving a sandbox

Archiving a sandbox frees its slot — only non-archived sandboxes count against the parent's `sandbox_orgs` quota. The mutation only archives sandbox organizations you administer; other targets are rejected with `SANDBOX_ARCHIVE_FORBIDDEN`.

```graphql
mutation {
  organization {
    archiveSandboxOrganization(id: "sandbox-org-uuid") {
      id
    }
  }
}
```

{% hint style="warning" %}
Sandboxes cannot be nested — only a real organization can be a parent. A sandbox can never create another sandbox.
{% endhint %}

***

## Efficient Migration Strategy

### Step 1: Update HTTP Client Configuration

Configure your HTTP client to automatically include context headers:

```javascript
// JavaScript/TypeScript example
const client = new GraphQLClient('https://api.durohub.com/graphql', {
  headers: {
    'x-api-key': process.env.DURO_API_KEY,
    'x-organization': '@my-company',
    'x-library': '@my-company/my-library',
  },
});
```

```python
# Python example
import requests

session = requests.Session()
session.headers.update({
    'x-api-key': os.environ['DURO_API_KEY'],
    'x-organization': '@my-company',
    'x-library': '@my-company/my-library',
    'Content-Type': 'application/json',
})
```

### Step 2: Search and Replace Input Objects

Use these regex patterns to find affected code:

```regex
# Find libraryId in input objects
libraryId\s*[:=]\s*["']?[^,}\s]+["']?

# Find library parameter in queries
\(library\s*[:=]

# Find organizationId in mutations
organizationId\s*[:=]
```

### Step 3: Update Query/Mutation Calls

1. Remove `libraryId` parameters from all input objects
2. Remove `library` and `libraryId` arguments from query/mutation calls
3. Ensure headers are set before making requests

### Step 4: Handle New Error Responses

Update error handling to recognize permission errors:

```javascript
try {
  const result = await client.request(query);
} catch (error) {
  if (error.response?.errors?.[0]?.extensions?.code === 'FORBIDDEN') {
    const requiredPermission = error.response.errors[0].extensions.requiredPermission;
    console.error(`Missing permission: ${requiredPermission}`);
  }
}
```

### Step 5: Test Thoroughly

1. Test all CRUD operations for components
2. Test change order workflows
3. Test configuration updates
4. Verify webhook functionality
5. Test with different user roles

***

## FAQ

### Q: Can I use both v1 and v2 patterns?

**A**: No. The v2 schema removes the parameters entirely. You must update all calls.

### Q: What if I access multiple libraries?

**A**: Change the `x-library` header for each request. Most HTTP clients support per-request header overrides.

### Q: How do I get my organization/library slugs?

**A**: View your URL in Duro: `durohub.com/org/@org-slug/libs/library-slug`. The slugs are the `@org-slug` and `library-slug` portions.

### Q: My API key was created for a specific library. Do I need a new one?

**A**: No, existing API keys continue to work. Just ensure your `x-library` header matches the library your key was created for.

### Q: What happens if headers are missing?

**A**: You'll receive a `400 Bad Request` error indicating which headers are required.

***

## Support

If you encounter issues during migration:

1. Check the [Error Handling](/advanced-topics/error-handling) guide
2. Review the GraphQL Playground at `https://api.durohub.com/graphql`
3. Contact support at <support@durolabs.co>

***

## Next Steps

* Explore the [GraphQL Playground](https://api.durohub.com/graphql) with the new schema
* Update your [Webhooks](/advanced-topics/webhooks) configuration if needed
* Review the complete permission list in your organization's Settings > Roles & Permissions


# Hello World

Let's make your first API call to Duro. This guide will walk you through a simple query to verify your setup.

## Prerequisites

Before making API calls, ensure you have:

* An API key (see [Authentication](/getting-started/authentication))
* Your organization slug (visible in your Duro URL: `durohub.com/org/@your-org/...`)

## Required Headers

All API requests require these headers:

| Header           | Description                                 |
| ---------------- | ------------------------------------------- |
| `x-api-key`      | Your API authentication token               |
| `x-organization` | Your organization slug (e.g., `@acme-corp`) |
| `Content-Type`   | Must be `application/json`                  |

{% hint style="info" %}
For library-scoped operations (like querying components), you'll also need the `x-library` header. See the [API v2 Migration Guide](/getting-started/api-v2-migration) for details.
{% endhint %}

## Your First Query

Here's a simple query to list all libraries in your organization:

```graphql
query GetLibraries {
  libraries {
    id
    name
    slug
    description
  }
}
```

### Example Response

```json
{
  "data": {
    "libraries": [
      {
        "id": "fc28b204-0cd0-46b3-96eb-b0720c16c423",
        "name": "Main Library",
        "slug": "main-library",
        "description": "Primary component library"
      }
    ]
  }
}
```

## Complete cURL Example

Here's a complete example using cURL:

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

## Next Steps

Now that you've verified your API access, learn about working with [Components](/core-concepts/components).


# Current User (Me)

`me` returns information about the account behind the current request. Use it to read identity details and to check what the current user is allowed to do before offering an action in your integration.

`me` is **not** a root query field — it lives under the `user` namespace:

```graphql
query {
  user {
    me {
      permitted
      canCreateOrg
      hasOrganizations
      user {
        id
        name
        primaryEmail
      }
    }
  }
}
```

## Fields

| Field              | Type       | Description                                                                                                                                                  |
| ------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `permitted`        | `Boolean!` | Whether the account is allowed to use the application at all. `false` means the caller authenticated successfully but is not permitted access.               |
| `canCreateOrg`     | `Boolean!` | Whether the account may create a new organization right now. See [below](#checking-whether-a-user-can-create-an-organization).                               |
| `hasOrganizations` | `Boolean!` | Whether the account belongs to at least one organization. Useful for hiding actions that require an organization to exist — creating a library, for example. |
| `user`             | `User`     | The caller's own user record, or `null` when no user record exists yet for the token.                                                                        |

Identity fields live on `user`, not on `Me` itself. Note that the `User` type exposes `primaryEmail` (a `String!`) and `emails` — there is no `email` field.

{% hint style="info" %}
`user` is nullable by design. A token that authenticates successfully but has no corresponding Duro user yet — during first-time bootstrap, for example — returns `user: null` with the three booleans still populated. Always null-check it.
{% endhint %}

## Checking whether a user can create an organization

Creating a new organization in Duro is gated: not every account is permitted to do so. Rather than attempting the mutation and handling a failure, query `canCreateOrg` first and use it to enable or hide a "New organization" affordance in your UI.

### `canCreateOrg: Boolean!`

Returns `true` when the current user is allowed to create a new organization right now, and `false` otherwise.

In deployments where organization-creation gating is **not enforced**, the gate is a no-op and `canCreateOrg` is `true` for every authenticated caller. Do not read a `true` here as evidence that a grant exists.

Where gating **is enforced**, `canCreateOrg` is `true` for a non-archived caller who either:

* holds a live, unused organization-creation grant issued to their verified email address, **or**
* is the Site Admin of at least one active production organization, and so may create a [sandbox organization](/advanced-topics/error-handling#sandbox-organization-errors) beneath it.

It is `false` when neither holds — when the user has no grant, when their grant has already been used or was revoked, or when they administer no organization that can parent a sandbox. Those conditions surface as [org-creation error codes](/advanced-topics/error-handling#organization-creation-errors) if a create is attempted anyway.

```json
{
  "data": {
    "user": {
      "me": {
        "permitted": true,
        "canCreateOrg": false,
        "hasOrganizations": true,
        "user": {
          "id": "b9f4b0f2-1c2a-4f7e-9a3c-2d5e8b1a7c40",
          "name": "Alex Rivera",
          "primaryEmail": "engineer@acme-corp.com"
        }
      }
    }
  }
}
```

{% hint style="info" %}
`canCreateOrg` reflects eligibility at the time of the query, and it deliberately skips the more expensive checks the create path performs. It does not verify that a prospective sandbox parent still has quota headroom, so it can report `true` when every organization you administer is already at its sandbox limit. Always treat the organization-creation mutation itself as the source of truth and handle its [error codes](/advanced-topics/error-handling#organization-creation-errors) — `canCreateOrg` is a hint for the UI, not a guarantee.
{% endhint %}


# Components

Components are the fundamental building blocks in Duro. They represent parts, assemblies, documents, and other items that make up your products. This guide covers how to query, create, and update components through the API.

## Overview

Every component in Duro has:

* A unique identifier (`id`)
* A name and optional description
* A revision value (e.g., "1.A", "2.B") tracking design iterations
* A category defining its type (Part, Assembly, Document, etc.)
* A status indicating its lifecycle state
* Attributes specific to its category

## Required Headers

All component operations require these headers:

```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": "..."}'
```

{% hint style="info" %}
The `x-library` header is required for all component operations since components belong to a specific library.
{% endhint %}

***

## Querying Components

### Get a Single Component

Retrieve a component by its ID:

```graphql
query GetComponent {
  component {
    findOne(id: "550e8400-e29b-41d4-a716-446655440000") {
      id
      name
      description
      revisionValue
      version
      state
      status {
        name
        color
      }
      category {
        name
        type
      }
      createdAt
      updatedAt
    }
  }
}
```

### List Components

Query multiple components with optional filtering and pagination:

```graphql
query ListComponents {
  component {
    findAll(
      filter: {
        categoryType: PART
        isArchived: false
      }
      pagination: { first: 20 }
    ) {
      edges {
        node {
          id
          name
          revisionValue
          version
          status {
            name
          }
          category {
            name
          }
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
}
```

### Filter Options

The `ComponentFilterInput` supports these fields:

| Field          | Type           | Description                                        |
| -------------- | -------------- | -------------------------------------------------- |
| `ids`          | `[ID!]`        | Filter by specific component IDs                   |
| `name`         | `String`       | Filter by name (partial match)                     |
| `names`        | `[String!]`    | Filter by multiple names                           |
| `exactNames`   | `[String!]`    | Filter by exact name matches                       |
| `cpns`         | `[String!]`    | Filter by Component Part Numbers                   |
| `statusId`     | `String`       | Filter by status ID                                |
| `categoryId`   | `String`       | Filter by category ID                              |
| `categoryType` | `CategoryType` | Filter by category type (ASSEMBLY, PART, DOCUMENT) |
| `isArchived`   | `Boolean`      | Include/exclude archived components                |
| `eid`          | `String`       | Filter by external identifier                      |

### Advanced Filtering

For complex queries with AND/OR logic, use the `filter` method:

```graphql
query AdvancedFilter {
  component {
    filter(input: {
      and: [
        { status: { name: "Released" } }
        { category: { type: PART } }
        { createdAt: { gte: "2024-01-01" } }
      ]
      pagination: { first: 50 }
    }) {
      edges {
        node {
          id
          name
          revisionValue
        }
      }
    }
  }
}
```

See [Searching and Filtering](/advanced-topics/searching-and-filtering) for comprehensive filtering options.

### Organizing Components into Groups

Group components by category, lifecycle status, type, modified-by user, or label — server-side, in a single call:

```graphql
query GroupByCategory {
  component {
    componentGroups(groupBy: CATEGORY, pagination: { first: 10 }) {
      edges {
        node {
          key
          label
          totalCount
          components {
            edges { node { id name } }
            pageInfo { hasNextPage }
          }
        }
      }
      totalCount
    }
  }
}
```

Each group returns a preview of up to 50 components. Groups with more items signal overflow via `components.pageInfo.hasNextPage`, and the remaining items can be loaded incrementally with the `componentsInGroup` query.

Grouping works with both simple and advanced filters — pass `filter` or `advancedFilter` alongside `groupBy` to scope results before grouping.

See [Component Grouping](/advanced-topics/component-grouping) for the full guide, including sorting within groups, sentinel keys for uncategorized items, and the complete API reference.

***

## Creating Components

Create one or more components using the `create` mutation:

```graphql
mutation CreateComponent {
  component {
    create(inputs: [{
      name: "Motor Assembly"
      description: "Primary drive motor assembly"
      categoryId: "category-uuid"
      attributeValues: [
        { attributeId: "attr-uuid-1", value: "12V" }
        { attributeId: "attr-uuid-2", value: "50W" }
      ]
    }]) {
      id
      name
      revisionValue
      version
      createdAt
    }
  }
}
```

### CreateComponentInput Fields

| Field             | Type                     | Required | Description                                         |
| ----------------- | ------------------------ | -------- | --------------------------------------------------- |
| `name`            | `String`                 | Yes      | Component name                                      |
| `description`     | `String`                 | No       | Detailed description                                |
| `categoryId`      | `String`                 | No       | Category/type for this component                    |
| `statusId`        | `ID`                     | No       | Initial status                                      |
| `revisionValue`   | `String`                 | No       | Initial revision (defaults based on library config) |
| `eid`             | `String`                 | No       | External identifier for integrations                |
| `attributeValues` | `[AttributeValueInput!]` | No       | Category-specific attribute values                  |
| `fileId`          | `ID`                     | No       | Attached file ID                                    |
| `imageFileId`     | `ID`                     | No       | Component image file ID                             |

### Example Response

```json
{
  "data": {
    "component": {
      "create": [
        {
          "id": "123e4567-e89b-12d3-a456-426614174000",
          "name": "Motor Assembly",
          "revisionValue": "1.A",
          "version": 1,
          "createdAt": "2024-01-15T10:30:00Z"
        }
      ]
    }
  }
}
```

***

## Updating Components

Update existing components using the `update` mutation:

```graphql
mutation UpdateComponent {
  component {
    update(inputs: [{
      id: "123e4567-e89b-12d3-a456-426614174000"
      name: "Motor Assembly v2"
      description: "Updated drive motor with improved efficiency"
      expectedVersion: 1
    }]) {
      id
      name
      description
      version
      updatedAt
    }
  }
}
```

{% hint style="warning" %}
**Optimistic Concurrency**: Use `expectedVersion` to prevent overwriting concurrent changes. If the current version doesn't match, the update will fail with a conflict error.
{% endhint %}

### UpdateComponentInput Fields

| Field             | Type                     | Description                        |
| ----------------- | ------------------------ | ---------------------------------- |
| `id`              | `ID!`                    | Component ID to update (required)  |
| `name`            | `String`                 | New name                           |
| `description`     | `String`                 | New description                    |
| `statusId`        | `ID`                     | New status                         |
| `revisionValue`   | `String`                 | New revision value                 |
| `categoryId`      | `ID`                     | Change category                    |
| `attributeValues` | `[AttributeValueInput!]` | Updated attribute values           |
| `isArchived`      | `Boolean`                | Archive/unarchive the component    |
| `expectedVersion` | `Int`                    | For optimistic concurrency control |

***

## Deleting Components

Delete components by their IDs:

```graphql
mutation DeleteComponents {
  component {
    delete(ids: ["component-uuid-1", "component-uuid-2"])
  }
}
```

{% hint style="warning" %}
Deleting components is permanent. Consider archiving instead by setting `isArchived: true` in an update mutation.
{% endhint %}

***

## Component States and Revisions

### State

Components have a `state` field indicating their modification status:

| State      | Description                      |
| ---------- | -------------------------------- |
| `RELEASED` | Component is released and locked |
| `MODIFIED` | Component has unreleased changes |

### Revisions

The `revisionValue` field tracks design iterations (e.g., "1.A", "1.B", "2.A"). Revision schemes are configured per-library. See [Revision Scheme](/library-configuration/revisions) for configuration options.

***

## Not Revision Managed (NRM)

A component can be flagged as **Not Revision Managed** (NRM) when you want to track it in Duro without revising it — for example, generic hardware or reference parts. An NRM component keeps a fixed revision and is held at Production status; its revision is not incremented on release.

NRM is gated per-library and applies only to parts and documents (not assemblies). It is available only when the library has the `notRevisionManaged` entitlement enabled and the caller has the corresponding permission.

### NRM fields on component reads

Two `Boolean` fields expose NRM state when querying a component:

| Field                            | Type      | Description                                                                                                                                                                                                                                                           |
| -------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `notRevisionManaged`             | `Boolean` | Whether the component is currently Not Revision Managed.                                                                                                                                                                                                              |
| `lastReleasedNotRevisionManaged` | `Boolean` | The NRM flag as of the component's most recent release. Returned only for components that have been released at least once (`null` otherwise). Use it to detect a Revision Managed ↔ NRM transition — for example, to establish the baseline for a change order item. |

```graphql
query GetComponentNrm {
  component {
    findOne(id: "550e8400-e29b-41d4-a716-446655440000") {
      id
      name
      revisionValue
      status {
        name
      }
      notRevisionManaged
      lastReleasedNotRevisionManaged
    }
  }
}
```

### NRM in search results

Search result nodes (`ComponentSearchResultDto`) also expose `notRevisionManaged`, so lightweight listing and search contexts can surface an NRM marker without loading the full component:

```graphql
query SearchNrm {
  component {
    filterWithAI(input: { query: "not revision managed parts" }) {
      components {
        id
        name
        cpn
        notRevisionManaged
      }
      totalCount
    }
  }
}
```

{% hint style="info" %}
Marking an Obsolete component as NRM is not allowed. Attempting it in an update returns the `NRM_NOT_ALLOWED_ON_OBSOLETE` validation error — see [Error Handling](/advanced-topics/error-handling).
{% endhint %}

***

## Working with Assemblies

Assemblies are components that contain other components. Query assembly structure using:

```graphql
query GetAssemblyStructure {
  component {
    findOne(id: "assembly-uuid") {
      id
      name
      category {
        type  # Will be ASSEMBLY
      }
    }
  }
}
```

{% hint style="info" %}
For detailed BOM (Bill of Materials) operations, use the `assemblies` namespace. See assembly documentation for more details.
{% endhint %}

***

## Complete cURL Example

Here's a complete example creating a component:

```bash
curl 'https://api.durohub.com/graphql' \
  -H 'x-api-key: YOUR_API_KEY' \
  -H 'x-organization: @acme-corp' \
  -H 'x-library: @acme-corp/main-library' \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "mutation CreateComponent($input: [CreateComponentInput!]!) { component { create(inputs: $input) { id name revisionValue } } }",
    "variables": {
      "input": [{
        "name": "Capacitor 100uF",
        "description": "Electrolytic capacitor, 100uF 25V",
        "categoryId": "part-category-uuid"
      }]
    }
  }'
```

***

## Sourcing

Components carry **sourcing** data describing how a part can be purchased: the manufacturer parts (identified by their MPN) associated with a component, and the distributor quotes (identified by their DPN) that price each manufacturer part. Duro also tracks which source is **primary** (preferred) and rolls sourcing up across an assembly's BOM.

### Manufacturer parts

A `Part` represents a manufacturer part number (MPN) linked to a component.

| Field    | Type     | Description                                  |
| -------- | -------- | -------------------------------------------- |
| `mpnUrl` | `String` | Link to the manufacturer's page for this MPN |

`mpnUrl` is also accepted on the create and update inputs:

| Input             | New field | Type     |
| ----------------- | --------- | -------- |
| `CreatePartInput` | `mpnUrl`  | `String` |
| `UpdatePartInput` | `mpnUrl`  | `String` |

### Distributor quotes

A `Quote` represents a distributor's offer (identified by a DPN) to supply a manufacturer part at a given price.

| Field         | Type     | Description                                 |
| ------------- | -------- | ------------------------------------------- |
| `dpnUrl`      | `String` | Link to the distributor's page for this DPN |
| `description` | `String` | Free-text description of the quote          |
| `unitPrice`   | `Float`  | Price per unit. Nullable — see note below   |

{% hint style="warning" %}
**Breaking-ish change:** `Quote.unitPrice` was previously non-nullable (`Float!`) and is now nullable (`Float`), so a quote can be recorded before its price is known. Clients that assumed a value is always present should handle `null`. Likewise, `CreateQuoteInput.unitPrice` is no longer required (`Float`, was `Float!`).
{% endhint %}

The new fields are accepted on the quote inputs as well:

| Input              | Change                                                                                                               |
| ------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `CreateQuoteInput` | added `dpnUrl: String` and `description: String`; `unitPrice` changed from required (`Float!`) to optional (`Float`) |
| `UpdateQuoteInput` | added `dpnUrl: String` and `description: String`                                                                     |

### Rollup priority state

`rollupPriorityState` reports whether a component's sourcing has been prioritized and whether the component is currently the primary source in its rollup context (for example, within an assembly).

```graphql
query GetRollupPriorityState($componentId: ID!) {
  rollupPriorityState(componentId: $componentId) {
    prioritized
    isPrimary
  }
}
```

`RollupPriorityState` fields:

| Field         | Description                                                           |
| ------------- | --------------------------------------------------------------------- |
| `prioritized` | Whether a priority ordering has been set for this component's sources |
| `isPrimary`   | Whether this component is the primary source in the current rollup    |

### Applying a sourcing changeset

`applySourcingChangeset` applies a batch of sourcing edits to a component **atomically** — adding and editing manufacturer parts and quotes, and setting the primary source — in a single mutation. It returns `Boolean!` (`true` on success).

```graphql
mutation ApplySourcingChangeset(
  $componentId: ID!
  $changeset: SourcingChangesetInput!
) {
  applySourcingChangeset(componentId: $componentId, changeset: $changeset)
}
```

`SourcingChangesetInput` groups the individual changes to apply in one call. It is composed of these member types:

| Type                   | Purpose                                                                                                              |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `ChangesetNewPart`     | A manufacturer part to add (mirrors `CreatePartInput`, including `mpnUrl`)                                           |
| `ChangesetNewQuote`    | A distributor quote to add (mirrors `CreateQuoteInput`, including `dpnUrl`, `description`, and optional `unitPrice`) |
| `ChangesetEditPart`    | Edits to an existing manufacturer part (mirrors `UpdatePartInput`)                                                   |
| `ChangesetEditQuote`   | Edits to an existing distributor quote (mirrors `UpdateQuoteInput`)                                                  |
| `ChangesetPrimary`     | Designates the primary source                                                                                        |
| `ChangesetPrimaryKind` | Enum identifying which kind of source a `ChangesetPrimary` targets (a manufacturer part vs. a quote)                 |

Because every change is applied together, a changeset that fails validation leaves the component's sourcing unchanged.

{% hint style="info" %}
For the exact field-level shape of `SourcingChangesetInput` and its member types, introspect the schema in [Apollo Explorer](https://api.durohub.com/graphql). This page documents the sourcing surface added to the API; Explorer always reflects the current contract.
{% endhint %}

***

## Next Steps

* Bulk-create and update components with [Importing Components](/core-concepts/importing-components)
* Learn about [Documents](/core-concepts/documents) associated with components
* Explore [Change Orders](/core-concepts/change-orders) for managing component modifications
* See [Searching and Filtering](/advanced-topics/searching-and-filtering) for advanced queries


# Importing Components

Duro supports bulk-importing components from a spreadsheet through the GraphQL API. An import runs in two steps — you **prepare** an import to validate it, then **start** it to write the results — and you poll the resulting job for progress and per-row outcomes.

{% hint style="warning" %}
**Imports are atomic (all-or-nothing).** If any row fails, the entire import is rolled back and nothing is written to your library. A row that passed validation is **not** guaranteed to have been written — see [Atomic behavior](#atomic-behavior-all-or-nothing) below.
{% endhint %}

## Required Headers

All import operations require the library headers:

```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": "..."}'
```

***

## The Import Workflow

Imports live under the `componentImport` namespace on both `Query` and `Mutation`.

### 1. Prepare (validate)

`prepare` parses and validates your rows and returns a `PreparedImportOutput` describing what would happen — how many components would be created, how many rows are valid, and any validation errors. Nothing is written to the library at this stage.

```graphql
mutation PrepareImport {
  componentImport {
    prepare(input: {
      libraryId: "library-uuid"
      fileId: "uploaded-file-uuid"
      keyColumn: CPN
    }) {
      id
      status
      totalComponents
      validCount
      errorCount
      newComponentCount
      linkedToExistingCount
      errors {
        rowNumber
        field
        code
        message
      }
    }
  }
}
```

The `keyColumn` (`NAME`, `EID`, or `CPN`) selects the identifier column used to match import rows to existing components for update mode. A row whose value resolves to exactly one live component is treated as an update of that component; otherwise it is a create.

### Column mappings

`prepare` accepts an optional list of `ColumnMappingInput`s telling Duro which spreadsheet column feeds which field. Each mapping pairs a `columnIndex` (or column header) with a `targetField`. Besides the plain component fields, `targetField` supports prefixed families — `attr:` for custom attributes, `hierarchy:` for category hierarchy values, `rel:` for relationships, and `src:` for sourcing.

#### Sourcing (`src:`) targets

Map a column to a `src:` target to bring a component's source — its manufacturer part and the quote for it — in on the same row as the component itself:

| `targetField`         | Maps to                                               |
| --------------------- | ----------------------------------------------------- |
| `src:manufacturer`    | Manufacturer name for the source                      |
| `src:mpn`             | Manufacturer part number                              |
| `src:mfrDescription`  | Description on the manufacturer part                  |
| `src:datasheet`       | Datasheet URL for the manufacturer part               |
| `src:distributor`     | Distributor name for the quote                        |
| `src:dpn`             | Distributor part number                               |
| `src:distDescription` | Description on the quote                              |
| `src:packageType`     | Package type on the quote (for example `Tape & Reel`) |
| `src:packageQuantity` | Units per package on the quote                        |
| `src:minQuantity`     | Minimum order quantity on the quote                   |
| `src:unitPrice`       | Price per unit                                        |
| `src:leadTime`        | Lead time for the quote                               |

A few things to know about the sourcing columns:

* **Values are carried through as written.** `"$1.00"` and `"1 Day"` are kept as the text you supplied rather than normalized at mapping time, so failure reports can echo back exactly what was in the cell.
* **Manufacturer, distributor, and package values stay as names.** They are resolved to sourcing records later, when the import is applied.
* **A row with every sourcing cell blank yields no source.** It is treated as a component-only row, not as an empty source.
* **One source per component row.** Additional sources on continuation rows are not supported; each row carries at most one source.

`src:` mappings travel through the same prepare/start flow as the rest of the import: `prepare` parses them alongside the component columns and reports any row errors, and `start` records the parsed sourcing rows as part of the job it creates.

#### Descriptions, datasheets, and package types

The four descriptive sourcing targets have behavior worth knowing before you build a sheet around them:

* **Descriptions are length-limited, and over-long values are refused.** `src:mfrDescription` and `src:distDescription` are held to the same length limit as the sourcing API's own `description` fields. A cell that exceeds it fails its row rather than being silently truncated — a half-stored note is a worse outcome than a rejected one.
* **`src:datasheet` must be an absolute URL.** The value is validated the same way the API validates a datasheet URL elsewhere, including requiring a protocol: `https://example.com/ds.pdf` is accepted, a bare `example.com/ds.pdf` is not. When the manufacturer part is created, the URL becomes a `DATASHEET` document linked to that part, committed together with it.
* **`src:packageType` is a lookup, never a create.** Unlike manufacturer and distributor names — which are your own data and are created on demand — package types come from a fixed, shared list. The name is matched case-insensitively; a name Duro does not recognize fails only that row, and the error lists the valid spellings (the usual culprit is punctuation, such as `Tape and Reel` for `Tape & Reel`). A name that matches two package types differing only by case is refused rather than guessed at.
* **Package types resolve before anything is written.** An unrecognized package type fails its row without having created a manufacturer part first.

{% hint style="info" %}
**Manufacturer parts are create-only today.** When a row's manufacturer and MPN match a manufacturer part that already exists, that part is reused as-is, so `src:mfrDescription` and `src:datasheet` take effect only on the row that first creates the part. Quote fields, including `src:distDescription` and `src:packageType`, are applied to the matched quote.
{% endhint %}

### 2. Start (execute)

Pass the `id` from the prepared import to `start`. This begins an `ImportJob` that stages and promotes the rows in a single transaction.

```graphql
mutation StartImport {
  componentImport {
    start(input: { preparedImportId: "prepared-import-uuid" }) {
      id
      status
    }
  }
}
```

### 3. Poll the job

Poll `componentImport.jobStatus` until the job reaches a terminal state. Per-row results are available on terminal-state jobs.

```graphql
query ImportJobStatus {
  componentImport {
    jobStatus(jobId: "import-job-uuid") {
      id
      status
      rowResultsSummary {
        total
        created
        updated
        unchanged
        skippedDuplicate
        failedValidation
        failedPromotion
        notImported
      }
      rowResults(offset: 0, limit: 500) {
        totalCount
        hasMore
        rows {
          rowNumber
          cpn
          name
          outcome
          errors {
            field
            code
            message
          }
        }
      }
    }
  }
}
```

An `ImportJob` moves through these statuses:

| Status                     | Meaning                                              |
| -------------------------- | ---------------------------------------------------- |
| `PARSING`                  | Reading the source rows                              |
| `STAGING_COMPONENTS`       | Writing components to the transaction                |
| `STAGING_LINKS`            | Writing assembly links to the transaction            |
| `PROMOTING`                | Committing the staged changes                        |
| `COMPLETED`                | Every row was written successfully                   |
| `ROLLING_BACK`             | A failure was hit; the transaction is being reverted |
| `FAILED`                   | The import was rejected; nothing was written         |
| `CANCELLING` / `CANCELLED` | The import was cancelled before completion           |

***

## Sourcing columns (`src:` targets)

A spreadsheet column can be mapped to a component field, to a library attribute (`attr:<attribute-id>`), or to a **sourcing** target using the `src:` prefix. Sourcing targets write the manufacturer part and distributor quote for the row's component instead of a field on the component itself — see [Sourcing](/core-concepts/components#sourcing) for the underlying model.

| Target                | Writes to                       | Notes                                                    |
| --------------------- | ------------------------------- | -------------------------------------------------------- |
| `src:manufacturer`    | Manufacturer name               | Identifies the manufacturer part together with `src:mpn` |
| `src:mpn`             | Manufacturer part number        |                                                          |
| `src:mfrDescription`  | Manufacturer part description   |                                                          |
| `src:datasheet`       | Manufacturer part datasheet URL |                                                          |
| `src:distributor`     | Distributor name                | Identifies the quote together with `src:dpn`             |
| `src:dpn`             | Distributor part number         |                                                          |
| `src:distDescription` | Quote description               |                                                          |
| `src:packageType`     | Quote package type              | Resolved by name — see below                             |
| `src:packageQuantity` | Quote package quantity          |                                                          |
| `src:minQuantity`     | Quote minimum order quantity    |                                                          |
| `src:unitPrice`       | Quote unit price                |                                                          |
| `src:leadTime`        | Quote lead time                 |                                                          |

Only the targets in this list are recognized. Mapping a column to an unknown `src:` target is rejected when you prepare the import.

### Package type resolution

`src:packageType` carries a human-readable package name (for example `0402` or `SOIC-8`), which is resolved against the package types Duro knows about. A value that matches nothing, or that matches more than one package type, fails with a dedicated error code — see [Sourcing import errors](/advanced-topics/error-handling#sourcing-import-errors).

{% hint style="info" %}
Sourcing values are not validated when you prepare the import; the component rows are. Sourcing is applied after the components themselves are written, so a sourcing failure does not roll back the component import — check the job's row errors to see which sourcing values were rejected.
{% endhint %}

***

## Atomic behavior (all-or-nothing)

Every import — create-only, update, or mixed — runs in a **single transaction**. Any error on any row (a create, an update, an assembly link, or a BOM reference) rolls back the whole import. On a rejected import **nothing is written to your library**, and no row is reported as `CREATED` or `UPDATED`.

This matters because import inserts components and links them as separate steps. Without atomicity, a parent row could fail while its children succeed, leaving orphaned children or a structurally broken BOM. All-or-nothing guarantees your library is never left in an incoherent state, and gives one predictable contract across create, update, and mixed sheets.

{% hint style="danger" %}
A row passing validation does **not** mean it was persisted. When the overall import is rejected because a *different* row failed, otherwise-valid rows are reported with the `NOT_IMPORTED` outcome. Always confirm the job reached `COMPLETED` (and check `rowResultsSummary`) before treating any row as written.
{% endhint %}

When an import is rejected, fix the offending row(s) reported in `rowResults`, then re-upload the sheet once — the rows that came back as `NOT_IMPORTED` were not partially applied and are safe to import again.

***

## Row Outcomes

Each row's `outcome` is an `ImportRowOutcome`:

| Outcome             | Meaning                                                                                                                     |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `CREATED`           | A new component was created from this row                                                                                   |
| `UPDATED`           | A matched component was updated because its data changed                                                                    |
| `UNCHANGED`         | The row equalled the existing component; a suppressed no-op                                                                 |
| `SKIPPED_DUPLICATE` | The row duplicated another row in the same import and was skipped                                                           |
| `FAILED_VALIDATION` | The row failed validation (see `errors`)                                                                                    |
| `FAILED_PROMOTION`  | The row was valid but failed while being committed (see `errors`)                                                           |
| `NOT_IMPORTED`      | The row was otherwise valid but the import was rejected because another row failed, so it was rolled back and never written |

`CREATED`, `UPDATED`, and `UNCHANGED` only appear when the whole job reaches `COMPLETED`. On a rejected import, the failing row(s) carry `FAILED_VALIDATION` or `FAILED_PROMOTION` and every other row carries `NOT_IMPORTED`.

## Results Summary

`rowResultsSummary` (`ImportRowResultsSummary`) reports counts by outcome across the whole job. Every field is a non-null `Int`:

| Field              | Meaning                                                                    |
| ------------------ | -------------------------------------------------------------------------- |
| `total`            | Total rows in the import                                                   |
| `created`          | Rows that created a new component                                          |
| `updated`          | Rows that updated an existing component                                    |
| `unchanged`        | Matched rows that were no-ops                                              |
| `skippedDuplicate` | Rows skipped as duplicates within the import                               |
| `failedValidation` | Rows that failed validation                                                |
| `failedPromotion`  | Rows that failed while being committed                                     |
| `notImported`      | Otherwise-valid rows that were rolled back because the import was rejected |

### Example: a rejected import

One bad row rejects the whole import. Here row 2 failed validation, and the remaining valid rows are reported as `notImported` rather than created:

```json
{
  "data": {
    "componentImport": {
      "jobStatus": {
        "status": "FAILED",
        "rowResultsSummary": {
          "total": 3,
          "created": 0,
          "updated": 0,
          "unchanged": 0,
          "skippedDuplicate": 0,
          "failedValidation": 1,
          "failedPromotion": 0,
          "notImported": 2
        },
        "rowResults": {
          "totalCount": 3,
          "hasMore": false,
          "rows": [
            { "rowNumber": 1, "cpn": "100-0001", "outcome": "NOT_IMPORTED", "errors": [] },
            {
              "rowNumber": 2,
              "cpn": "100-0002",
              "outcome": "FAILED_VALIDATION",
              "errors": [
                { "field": "name", "code": "REQUIRED", "message": "Name is required" }
              ]
            },
            { "rowNumber": 3, "cpn": "100-0003", "outcome": "NOT_IMPORTED", "errors": [] }
          ]
        }
      }
    }
  }
}
```

Because `created` is `0` and `status` is `FAILED`, no component was written — including row 1 and row 3, which validated fine. Fix row 2 and re-upload.

***

## Next Steps

* Learn how to [create and update Components](/core-concepts/components) individually
* See [Error Handling](/advanced-topics/error-handling) for general API error patterns


# Documents

Learn how to manage technical documentation and files through the Duro API.

### Document Types

Duro supports various document types:

* CAD Files
* Technical Drawings
* Specifications
* Test Reports
* Quality Documentation

### Querying Documents

Here's how to fetch documents:

```graphql
query {
  documents(first: 10) {
    edges {
      node {
        id
        title
        fileType
        version
        createdAt
        createdBy {
          name
        }
      }
    }
  }
}
```

### Document Operations

```graphql
mutation {
  createDocument(input: {
    title: "Assembly Instructions"
    componentId: "comp_123"
    fileUrl: "https://example.com/file.pdf"
  }) {
    document {
      id
      title
    }
  }
}
```

### File Management

* Direct upload URLs
* Version control
* Access permissions
* File metadata

***

## Documents on Change Orders

Documents can be attached to change orders as supporting reference material. See [Attaching Documents to a Change Order](/core-concepts/change-orders#attaching-documents-to-a-change-order) for usage.

### Querying Documents on a Change Order

```graphql
query GetCODocuments {
  changeOrders {
    get(filter: { ids: ["123e4567-e89b-12d3-a456-426614174000"] }) {
      connection {
        edges {
          node {
            id
            name
            documents {
              id
              componentId
              componentVersion
            }
          }
        }
      }
    }
  }
}
```

### Next Steps

Learn about managing product changes with [Change Orders](/core-concepts/change-orders).


# Change Orders

Change Orders (COs) are the backbone of engineering change management in PLM systems. They provide a structured, auditable process for proposing, reviewing, and implementing modifications to your components. Whether you're updating a component specification, revising a BOM, or making design changes, change orders ensure proper review, approval, and traceability.

## Overview

### What Are Change Orders?

A Change Order is a formal request to modify one or more components in your library. It captures:

* **What's changing**: The affected components and proposed modifications
* **Why it's changing**: Description and justification for the change
* **Who approves it**: Reviewers assigned to evaluate the change
* **The outcome**: Whether the change was approved, rejected, or withdrawn

### Change Order Lifecycle

Change orders follow a defined lifecycle:

```
DRAFT ──────► OPEN ──────► RESOLVED ──────► CLOSED
  │             │              │
  │             ▼              │
  │         ON_HOLD            │
  │             │              │
  └─────────────┴──────────────┘
            (reset)
```

| Status     | Description                                                                                |
| ---------- | ------------------------------------------------------------------------------------------ |
| `DRAFT`    | Initial state. The CO can be modified freely—add items, assign reviewers, fill in details. |
| `OPEN`     | Submitted for review. Reviewers evaluate and make decisions.                               |
| `ON_HOLD`  | Temporarily paused, awaiting additional information.                                       |
| `RESOLVED` | Review complete. Check the `resolution` field for the outcome.                             |
| `CLOSED`   | Final state. The change order is complete.                                                 |

### Resolution Types

When a change order is resolved, the `resolution` field indicates the outcome:

| Resolution  | Description                       |
| ----------- | --------------------------------- |
| `PENDING`   | Still awaiting reviewer decisions |
| `APPROVED`  | All required approvals obtained   |
| `REJECTED`  | Rejected by one or more reviewers |
| `WITHDRAWN` | Creator withdrew the change order |

### Change Order Types

Every change order carries a **type** (`coType`), a classification drawn from the industry-standard `CoType` enum. The type is fixed when the change order is created — there is no mutation to change it afterward — and is returned on every change order as the non-null `coType` field.

| Value | Meaning                      |
| ----- | ---------------------------- |
| `ECO` | Engineering Change Order     |
| `MCO` | Manufacturing Change Order   |
| `DCO` | Documentation Change Order   |
| `ECR` | Engineering Change Request   |
| `MCR` | Manufacturing Change Request |
| `DCR` | Documentation Change Request |
| `ECN` | Engineering Change Notice    |
| `MCN` | Manufacturing Change Notice  |
| `DCN` | Documentation Change Notice  |

For most types the value is **classification metadata only** — it records intent and drives reporting, but does not change how the change order behaves.

{% hint style="info" %}
**`DCO` is the exception — it changes behavior.** A Documentation Change Order is revision-frozen: approving and closing it never bumps the revision of any component in the change order, regardless of whether the component was modified. Consequently a DCO also **cannot change a component's `status` or `revision`** — attempting either is rejected ([`DCO_STATUS_CHANGE_NOT_ALLOWED`, `DCO_REVISION_CHANGE_NOT_ALLOWED`](/advanced-topics/error-handling#dco-status-change-not-allowed-dco-revision-change-not-allowed)). Use a DCO for documentation-only updates that need visibility across your team without triggering a revision bump.
{% endhint %}

{% hint style="warning" %}
**A DCO is frozen against the last-released baseline.** Duro compares every DCO item's *live* `status` and `revision` against its *last-released baseline* and blocks the change order if they have drifted apart — both when you submit it for review and again when it closes. Two cases are worth calling out:

* **Concurrent drift.** If another change order releases a new revision of one of your DCO's items (or changes its status) after you added it, the DCO can no longer be released. Re-add the item against the new baseline, or move the change to a non-`DCO` type.
* **No releasable baseline.** If a DCO item has never been released — so there is no baseline to freeze against — or its live component can't be found, the DCO is likewise blocked.

Drift caught at close time aborts the release with [`DCO_STATUS_CHANGE_NOT_ALLOWED` / `DCO_REVISION_CHANGE_NOT_ALLOWED`](/advanced-topics/error-handling#dco-status-change-not-allowed-dco-revision-change-not-allowed). The guard is the built-in [DCO baseline validation](/library-configuration/change-order-validations#dco-baseline-freeze).
{% endhint %}

**How the type is chosen at creation:**

1. If you pass an explicit `coType` in the create input, that value is used.
2. Otherwise, when creating from a template, the template's `defaultCoType` is used.
3. Otherwise (a template-less change order with no `coType`), the type defaults to `ECO`.

When a template is used, the resolved type must be one of the template's allowed `coTypes` — otherwise creation fails with [`CO_TYPE_NOT_ALLOWED`](/advanced-topics/error-handling#co-type-not-allowed). Templates declare their allowed types in the workflow YAML; see [Change Order Workflows → Declaring Allowed Change Order Types](/library-configuration/change-order-workflows#declaring-allowed-change-order-types).

***

## Required Headers

All change order operations require these headers:

```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": "..."}'
```

| Header           | Required | Description                                    |
| ---------------- | -------- | ---------------------------------------------- |
| `x-api-key`      | Yes      | Your API authentication token                  |
| `x-organization` | Yes      | Organization slug (e.g., `@acme-corp`)         |
| `x-library`      | Yes      | Library slug (e.g., `@acme-corp/main-library`) |
| `Content-Type`   | Yes      | Must be `application/json`                     |

***

## Querying Change Orders

### List Change Orders

Query change orders with optional filtering and pagination:

```graphql
query GetChangeOrders {
  changeOrders {
    get(
      filter: { status: [DRAFT, OPEN] }
      pagination: { first: 20 }
    ) {
      connection {
        edges {
          node {
            id
            sequentialId
            name
            description
            status
            resolution
            createdAt
            createdBy {
              firstName
              lastName
              email
            }
            currentStage {
              name
              decisionState
            }
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
      totalCount
    }
  }
}
```

### Filter Options

| Field           | Type                       | Description                                                   |
| --------------- | -------------------------- | ------------------------------------------------------------- |
| `ids`           | `[ID!]`                    | Filter by specific UUIDs                                      |
| `sequentialIds` | `[Int!]`                   | Filter by sequential IDs (e.g., CO-001, CO-002)               |
| `status`        | `[ChangeOrderStatus!]`     | Filter by status (DRAFT, OPEN, RESOLVED, CLOSED, ON\_HOLD)    |
| `resolution`    | `[ChangeOrderResolution!]` | Filter by resolution (PENDING, APPROVED, REJECTED, WITHDRAWN) |
| `assigneeId`    | `ID`                       | Filter by assigned reviewer                                   |
| `stageId`       | `ID`                       | Filter by current stage                                       |

***

## Getting Available Templates

Before creating a change order, query the available templates for your library:

```graphql
query GetTemplates {
  changeOrders {
    getTemplates {
      id
      name
      config
      coTypes
      defaultCoType
      createdAt
    }
  }
}
```

Templates define the workflow structure including:

* Custom fields to capture change information
* Approval stages and their decision methods
* Default reviewers and notification settings
* The [change order types](#change-order-types) the template allows (`coTypes`) and its default (`defaultCoType`)

{% hint style="info" %}
`coTypes` and `defaultCoType` are only populated for templates authored against schema version `1.1` or later. Legacy (`1.0`) templates return `null` for both fields and behave as if they allow only `ECO`. See the [Workflow YAML Reference](/library-configuration/change-order-workflow-reference#change-order-types).
{% endhint %}

See [Change Order Workflows](/library-configuration/change-order-workflows) for creating custom templates.

***

## Creating a Change Order

Create a new change order in `DRAFT` status:

```graphql
mutation CreateChangeOrder {
  changeOrders {
    create(input: {
      name: "ECO-2024-001: Update Motor Specifications"
      description: "Updating motor torque specifications to meet new performance requirements"
      coType: ECO
    }) {
      id
      sequentialId
      name
      description
      status
      resolution
      coType
      stages {
        id
        name
        order
        decisionMethod
      }
      contents {
        id
        name
        label
        isRequired
      }
      createdAt
    }
  }
}
```

The `coType` input is optional. When omitted, the server resolves the type from the template's `defaultCoType`, falling back to `ECO` for template-less change orders. If you pass a `coType` that the template does not allow, creation fails with [`CO_TYPE_NOT_ALLOWED`](/advanced-topics/error-handling#co-type-not-allowed). See [Change Order Types](#change-order-types) for the full list of values and the behavior of `DCO`.

### Example Response

```json
{
  "data": {
    "changeOrders": {
      "create": {
        "id": "123e4567-e89b-12d3-a456-426614174000",
        "sequentialId": 42,
        "name": "ECO-2024-001: Update Motor Specifications",
        "description": "Updating motor torque specifications...",
        "status": "DRAFT",
        "resolution": "PENDING",
        "coType": "ECO",
        "stages": [
          {
            "id": "stage-uuid-1",
            "name": "Engineering Review",
            "order": 1,
            "decisionMethod": "MAJORITY"
          },
          {
            "id": "stage-uuid-2",
            "name": "Quality Approval",
            "order": 2,
            "decisionMethod": "UNANIMOUS"
          }
        ],
        "contents": [
          {
            "id": "content-uuid-1",
            "name": "change_category",
            "label": "Change Category",
            "isRequired": true
          }
        ],
        "createdAt": "2024-01-15T10:30:00Z"
      }
    }
  }
}
```

***

## Adding Items to a Change Order

Add components to the change order to indicate what will be modified:

```graphql
mutation AddItems {
  changeOrders {
    addItems(
      changeOrderId: "123e4567-e89b-12d3-a456-426614174000"
      input: {
        items: [
          { id: "component-uuid-1", version: 3 }
          { id: "component-uuid-2", version: 1 }
        ]
      }
    ) {
      id
      itemId
      itemVersion
      proposedRevision
      proposedStatusId
    }
  }
}
```

### Setting Proposed Changes

You can specify what the component will become after the change order is approved:

```graphql
mutation UpdateProposals {
  changeOrders {
    updateProposalsForItems(
      changeOrderId: "123e4567-e89b-12d3-a456-426614174000"
      input: {
        items: [
          {
            itemId: "component-uuid-1"
            revision: "2.A"
            statusId: "released-status-uuid"
          }
        ]
      }
    ) {
      id
      itemId
      proposedRevision
      proposedStatusId
    }
  }
}
```

### Removing Items

```graphql
mutation RemoveItems {
  changeOrders {
    removeItems(
      changeOrderId: "123e4567-e89b-12d3-a456-426614174000"
      input: {
        itemIds: ["component-uuid-2"]
      }
    ) {
      id
      itemId
    }
  }
}
```

***

## Attaching Documents to a Change Order

Attach supporting documents to a change order in `DRAFT` status. Unlike items, which have dedicated mutations, documents are managed through `submitDraft`:

### Adding Documents

```graphql
mutation AttachDocuments {
  changeOrders {
    submitDraft(
      id: "123e4567-e89b-12d3-a456-426614174000"
      input: {
        documents: {
          add: [
            { componentId: "document-uuid-1", componentVersion: 1 }
            { componentId: "document-uuid-2", componentVersion: 3 }
          ]
        }
      }
    ) {
      id
      documents {
        id
        componentId
        componentVersion
      }
    }
  }
}
```

### Removing Documents

```graphql
mutation RemoveDocuments {
  changeOrders {
    submitDraft(
      id: "123e4567-e89b-12d3-a456-426614174000"
      input: {
        documents: {
          remove: ["document-link-uuid-1"]
        }
      }
    ) {
      id
      documents {
        id
      }
    }
  }
}
```

{% hint style="info" %}
The `remove` field takes **document link IDs** (the `id` of each `ChangeOrderDocumentLink`), not component IDs. Query the change order's `documents` field to get these IDs.
{% endhint %}

***

## Setting Custom Field Values

If your template defines custom fields (like "Impact Assessment" or "Change Category"), set their values:

```graphql
mutation SetContentValue {
  changeOrders {
    setContentValue(
      contentId: "content-uuid-1"
      input: {
        values: ["design"]
      }
    ) {
      id
      name
      label
      values
    }
  }
}
```

{% hint style="info" %}
The `contentId` comes from the `contents` array in the change order response. Each content field has a unique ID.
{% endhint %}

***

## Adding Reviewers to Stages

Assign users to review each approval stage:

```graphql
mutation AddReviewers {
  changeOrders {
    addReviewersToStages(
      changeOrderId: "123e4567-e89b-12d3-a456-426614174000"
      input: {
        stages: [
          {
            stageId: "stage-uuid-1"
            userIds: ["user-uuid-1", "user-uuid-2"]
          }
          {
            stageId: "stage-uuid-2"
            userIds: ["user-uuid-3"]
          }
        ]
      }
    ) {
      id
      stages {
        id
        name
        reviewers {
          id
          user {
            firstName
            lastName
            email
          }
          decisionState
        }
      }
    }
  }
}
```

### Removing Reviewers

```graphql
mutation RemoveReviewers {
  changeOrders {
    removeReviewersFromStages(
      changeOrderId: "123e4567-e89b-12d3-a456-426614174000"
      input: {
        stages: [
          {
            stageId: "stage-uuid-1"
            userIds: ["user-uuid-2"]
          }
        ]
      }
    ) {
      id
      stages {
        id
        reviewers {
          id
        }
      }
    }
  }
}
```

***

## Configuring Stage Decision Methods

Each stage can use different approval logic:

| Method      | Description                       |
| ----------- | --------------------------------- |
| `UNANIMOUS` | All reviewers must approve        |
| `MAJORITY`  | More than 50% must approve        |
| `MINIMUM`   | At least N reviewers must approve |

```graphql
mutation UpdateDecisionMethods {
  changeOrders {
    updateDecisionMethodsForStages(
      changeOrderId: "123e4567-e89b-12d3-a456-426614174000"
      input: {
        stages: [
          {
            stageId: "stage-uuid-1"
            decisionMethod: MAJORITY
          }
          {
            stageId: "stage-uuid-2"
            decisionMethod: MINIMUM
            decisionMinimumCount: 2
          }
        ]
      }
    ) {
      id
      stages {
        id
        name
        decisionMethod
      }
    }
  }
}
```

***

## Submitting for Review

When your change order is ready, submit it for review. This transitions the status from `DRAFT` to `OPEN`:

```graphql
mutation SubmitForReview {
  changeOrders {
    submitForReview(id: "123e4567-e89b-12d3-a456-426614174000") {
      id
      status
      resolution
      currentStage {
        name
        decisionState
      }
    }
  }
}
```

{% hint style="warning" %}
**Before Submitting**: Ensure all required content fields are populated and at least one item is added. Use the `canSubmit` and `cannotSubmitReasons` fields to check readiness.
{% endhint %}

### Check Submission Readiness

```graphql
query CheckReadiness {
  changeOrders {
    get(filter: { ids: ["123e4567-e89b-12d3-a456-426614174000"] }) {
      connection {
        edges {
          node {
            id
            canSubmit
            cannotSubmitReasons
          }
        }
      }
    }
  }
}
```

***

## Making Review Decisions

Reviewers approve or reject their assigned stages:

### Approving

```graphql
mutation ApproveStage {
  changeOrders {
    updateReviewDecision(
      stageId: "stage-uuid-1"
      input: {
        decisionState: APPROVED
        note: "Reviewed and approved. All specifications meet requirements."
      }
    ) {
      id
      decisionState
      note
      decidedAt
      user {
        firstName
        lastName
      }
    }
  }
}
```

### Rejecting

```graphql
mutation RejectStage {
  changeOrders {
    updateReviewDecision(
      stageId: "stage-uuid-1"
      input: {
        decisionState: REJECTED
        note: "Missing thermal analysis documentation. Please add before resubmission."
      }
    ) {
      id
      decisionState
      note
      decidedAt
    }
  }
}
```

{% hint style="info" %}
When a stage meets its decision criteria (based on the decision method), the change order automatically progresses to the next stage or resolves.
{% endhint %}

***

## Completing Change Orders

### Closing an Approved Change Order

If `approvalRouterOption` is `MANUAL_CLOSE`, you need to explicitly close the change order:

```graphql
mutation CloseChangeOrder {
  changeOrders {
    close(id: "123e4567-e89b-12d3-a456-426614174000") {
      id
      status
      resolution
    }
  }
}
```

### Withdrawing a Change Order

The creator can withdraw a change order at any time:

```graphql
mutation WithdrawChangeOrder {
  changeOrders {
    withdraw(id: "123e4567-e89b-12d3-a456-426614174000") {
      id
      status
      resolution
    }
  }
}
```

### Resetting to Draft

Reset a change order back to `DRAFT` to make modifications and resubmit:

```graphql
mutation ResetChangeOrder {
  changeOrders {
    reset(id: "123e4567-e89b-12d3-a456-426614174000") {
      id
      status
      resolution
    }
  }
}
```

{% hint style="info" %}
**When to Reset**: Use reset after a rejection to modify the change order and resubmit, or after withdrawal if you want to revive the change order.
{% endhint %}

***

## Complete Workflow Example

Here's a complete example showing the typical happy path:

```javascript
const { GraphQLClient } = require('graphql-request');

const client = new GraphQLClient('https://api.durohub.com/graphql', {
  headers: {
    'x-api-key': process.env.DURO_API_KEY,
    'x-organization': '@acme-corp',
    'x-library': '@acme-corp/main-library',
  },
});

async function createAndSubmitChangeOrder() {
  // Step 1: Create the change order
  const createResult = await client.request(`
    mutation CreateCO($input: CreateChangeOrderInput!) {
      changeOrders {
        create(input: $input) {
          id
          stages { id name }
          contents { id name }
        }
      }
    }
  `, {
    input: {
      name: "ECO-2024-001: Motor Specification Update",
      description: "Update torque specs for Model X motor"
    }
  });

  const coId = createResult.changeOrders.create.id;
  const stageId = createResult.changeOrders.create.stages[0].id;
  console.log(`Created CO: ${coId}`);

  // Step 2: Add items (components)
  await client.request(`
    mutation AddItems($coId: ID!, $input: AddItemsInput!) {
      changeOrders {
        addItems(changeOrderId: $coId, input: $input) {
          id
        }
      }
    }
  `, {
    coId,
    input: {
      items: [{ id: "component-uuid", version: 1 }]
    }
  });
  console.log("Items added");

  // Step 3: Attach reference documents
  await client.request(`
    mutation AttachDocuments($id: ID!, $input: SubmitDraftInput!) {
      changeOrders {
        submitDraft(id: $id, input: $input) {
          id
          documents { id componentId componentVersion }
        }
      }
    }
  `, {
    id: coId,
    input: {
      documents: {
        add: [
          { componentId: "test-report-uuid", componentVersion: 1 },
          { componentId: "spec-sheet-uuid", componentVersion: 2 }
        ]
      }
    }
  });
  console.log("Reference documents attached");

  // Step 4: Add reviewers
  await client.request(`
    mutation AddReviewers($coId: ID!, $input: AddReviewersToStagesInput!) {
      changeOrders {
        addReviewersToStages(changeOrderId: $coId, input: $input) {
          id
        }
      }
    }
  `, {
    coId,
    input: {
      stages: [{ stageId, userIds: ["reviewer-uuid"] }]
    }
  });
  console.log("Reviewers added");

  // Step 5: Submit for review
  const submitResult = await client.request(`
    mutation Submit($id: ID!) {
      changeOrders {
        submitForReview(id: $id) {
          status
          resolution
        }
      }
    }
  `, { id: coId });
  console.log(`Status: ${submitResult.changeOrders.submitForReview.status}`);

  // Step 6: Reviewer approves (would be done by the reviewer)
  await client.request(`
    mutation Approve($stageId: ID!, $input: UpdateReviewDecisionInput!) {
      changeOrders {
        updateReviewDecision(stageId: $stageId, input: $input) {
          decisionState
        }
      }
    }
  `, {
    stageId,
    input: {
      decisionState: "APPROVED",
      note: "Approved - specifications verified"
    }
  });
  console.log("Approved!");

  // Step 7: Close the change order
  const closeResult = await client.request(`
    mutation Close($id: ID!) {
      changeOrders {
        close(id: $id) {
          status
          resolution
        }
      }
    }
  `, { id: coId });
  console.log(`Final status: ${closeResult.changeOrders.close.status}`);
}

createAndSubmitChangeOrder().catch(console.error);
```

***

## Handling Rejection

When a change order is rejected:

1. **Review the rejection notes** to understand what needs to change
2. **Reset to DRAFT** using the `reset` mutation
3. **Make necessary modifications** (update items, content, etc.)
4. **Resubmit** using `submitForReview`

```graphql
# After rejection, reset and fix
mutation ResetAndFix {
  changeOrders {
    reset(id: "co-uuid") {
      status  # Returns to DRAFT
    }
  }
}

# Make your changes, then resubmit
mutation Resubmit {
  changeOrders {
    submitForReview(id: "co-uuid") {
      status
      resolution
    }
  }
}
```

***

## Best Practices

### Planning

* **Use descriptive names**: Include an identifier (e.g., "ECO-2024-001") and brief description
* **Choose the right template**: Select a template that matches your change type
* **Complete all required fields**: Fill out custom content fields before submission

### Items and Scope

* **Include all affected components**: Add every component that will be modified
* **Specify correct versions**: Reference the specific version being changed
* **Keep scope focused**: One change order should address one logical change

### Review Process

* **Add appropriate reviewers**: Ensure all required stakeholders are included
* **Provide context in notes**: When approving or rejecting, include detailed notes
* **Check `canSubmit` before submitting**: Avoid submission errors

### Workflow Management

* **Don't skip states**: Follow the proper lifecycle transitions
* **Close completed COs promptly**: Move approved COs to CLOSED when changes are implemented
* **Document rejection reasons**: Provide actionable feedback when rejecting

***

## Next Steps

* [**Documents**](/core-concepts/documents): Learn about document management and how documents relate to change orders
* [**Change Order Workflows**](/library-configuration/change-order-workflows): Create custom templates with multi-stage approvals
* [**Change Order Workflow Reference**](/library-configuration/change-order-workflow-reference): Complete YAML specification
* [**Change Order Validations**](/library-configuration/change-order-validations): Implement validation rules
* [**Webhooks**](/advanced-topics/webhooks): Get notified when change orders are updated


# Searching and Filtering

Build powerful, precise queries to find exactly the components you need. This guide covers both our **Advanced Filter API** for programmatic queries and our **AI-Powered Filter API** for natural language searches.

{% hint style="info" %}
**New: Structured Query Syntax** — You can also use a `key:value` search syntax in the web app's search bar and via the `buildFilterFromQuery` / `filterWithQuery` APIs. See the [Search Query Syntax Reference](/advanced-topics/search-query-syntax) for the complete guide.
{% endhint %}

## Quick Start

Let's jump straight in. Here's a query to find all components in "Design" status that were modified in the last 30 days:

```graphql
query FilterComponents {
  component {
    filter(
      input: {
        and: [
          { status: { name: { eq: "Design" } } }
          { updatedAt: { after: "2024-12-01" } }
        ]
      }
    ) {
      edges {
        node {
          id
          name
          cpn
          status {
            name
          }
        }
      }
      totalCount
    }
  }
}
```

Or skip writing filters entirely—let AI do the work and get components in a single call:

```graphql
query SearchWithAI {
  component {
    filterWithAI(input: { query: "parts modified by Sarah in the last week" }) {
      components {
        id
        name
        cpn
      }
      totalCount
      interpretation
      success
    }
  }
}
```

That's it—one query, natural language in, components out!

{% hint style="info" %}
**New to the Duro API?** Make sure you've set up [authentication](/getting-started/authentication) first. All examples assume you have a valid API token.
{% endhint %}

***

## Part 1: Advanced Filter API

The Advanced Filter API gives you precise, programmatic control over component queries using a Linear-style AND/OR logic system.

### API Structure Overview

```
AdvancedComponentFilterInput
├── and: [ComponentFilterConditionInput]  ← All conditions must match
├── or: [ComponentFilterConditionInput]   ← Any condition can match
└── pagination: PaginationInput           ← Control page size and sorting
```

Each `ComponentFilterConditionInput` can filter by one or more fields:

| Field          | Type                         | Description                                                                   |
| -------------- | ---------------------------- | ----------------------------------------------------------------------------- |
| `status`       | StatusFilterInput            | Filter by status ID, name, or type (DESIGN, PRODUCTION, etc.)                 |
| `category`     | ComponentCategoryFilterInput | Filter by category ID, name, or type (PART, ASSEMBLY, DOCUMENT)               |
| `state`        | StateFilterInput             | Filter by component state (RELEASED, MODIFIED)                                |
| `revision`     | RevisionFilterInput          | Filter by revision value with ordering support                                |
| `createdBy`    | UserFilterInput              | Filter by who created the component                                           |
| `updatedBy`    | UserFilterInput              | Filter by who last modified the component                                     |
| `labels`       | LabelFilterInput             | Filter by assigned labels                                                     |
| `isPinned`     | BooleanFilterInput           | Filter pinned/unpinned components                                             |
| `isBookmarked` | BooleanFilterInput           | Filter bookmarked components                                                  |
| `createdAt`    | DateFilterInput              | Filter by creation date                                                       |
| `updatedAt`    | DateFilterInput              | Filter by last update date                                                    |
| `name`         | StringOperators              | Filter by component name (exact via `eq`, or `like`/`notLike` for patterns)   |
| `identifier`   | IdentifierFilterInput        | Filter by CPN via `displayValue` (String Operators)                           |
| `description`  | DescriptionFilterInput       | Full-text `contains`/`doesNotContain`, plus `like`/`notLike` pattern matching |

### Operators Reference

#### String Operators

Use these for filtering text fields like status names, category names, and labels:

| Operator      | Description                                                                                             | Example                                           |
| ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `eq`          | Exact match                                                                                             | `{ name: { eq: "Design" } }`                      |
| `neq`         | Not equal                                                                                               | `{ name: { neq: "Obsolete" } }`                   |
| `in`          | Match any in list                                                                                       | `{ name: { in: ["Design", "Development"] } }`     |
| `notIn`       | Exclude all in list                                                                                     | `{ name: { notIn: ["Obsolete", "Deprecated"] } }` |
| `contains`    | Substring match (case-insensitive)                                                                      | `{ name: { contains: "resistor" } }`              |
| `notContains` | Does not contain                                                                                        | `{ name: { notContains: "test" } }`               |
| `like`        | Pattern match, case-insensitive: `%` matches any sequence of characters, `_` matches a single character | `{ name: { like: "401-%" } }`                     |
| `notLike`     | Does not match the pattern                                                                              | `{ name: { notLike: "%draft%" } }`                |

{% hint style="info" %}
`like`/`notLike` match against a pattern where `%` stands for any sequence of characters and `_` for a single character. The search bar's `*` wildcard maps to `%` here — so search-bar `cpn:401-*` is equivalent to `{ identifier: { displayValue: { like: "401-%" } } }`. See the [Search Query Syntax](/advanced-topics/search-query-syntax#wildcards) reference.
{% endhint %}

#### Date Operators

Use these for `createdAt` and `updatedAt` fields:

| Operator                  | Description                 | Example                                                |
| ------------------------- | --------------------------- | ------------------------------------------------------ |
| `before`                  | Date is before (exclusive)  | `{ before: "2024-06-01" }`                             |
| `after`                   | Date is after (exclusive)   | `{ after: "2024-01-01" }`                              |
| `on`                      | Exact date match (same day) | `{ on: "2024-03-15" }`                                 |
| `rangeStart` + `rangeEnd` | Date range (inclusive)      | `{ rangeStart: "2024-01-01", rangeEnd: "2024-03-31" }` |

#### Revision Operators

Revisions support ordering comparisons. The ordering follows your library's revision scheme:

* **Letter sequences**: A < B < ... < Z < AA < AB
* **Integers**: 1 < 2 < 3 < ... < 999
* **Multi-segment**: Compared segment by segment (A.1 < A.2 < B.1)

| Operator | Description  | Example                                      |
| -------- | ------------ | -------------------------------------------- |
| `eq`     | Exact match  | `{ value: { eq: "B" } }`                     |
| `neq`    | Not equal    | `{ value: { neq: "A" } }`                    |
| `in`     | Match any    | `{ value: { in: ["A", "B", "C"] } }`         |
| `notIn`  | Exclude all  | `{ value: { notIn: ["A"] } }`                |
| `lt`     | Less than    | `{ value: { lt: "C" } }` ← Matches A, B      |
| `gt`     | Greater than | `{ value: { gt: "B" } }` ← Matches C, D, ... |

#### Boolean Operators

Simple true/false matching:

| Operator    | Example                           |
| ----------- | --------------------------------- |
| `eq: true`  | `{ isPinned: { eq: true } }`      |
| `eq: false` | `{ isBookmarked: { eq: false } }` |

***

### Practical Examples

#### Example 1: Find Production-Ready Components

Find all components with a production-type status that have been released:

```graphql
query ProductionReadyComponents {
  component {
    filter(
      input: {
        and: [
          { status: { mapsTo: { eq: "PRODUCTION" } } }
          { state: { eq: RELEASED } }
        ]
      }
    ) {
      edges {
        node {
          id
          name
          cpn
          revision {
            value
          }
        }
      }
      totalCount
    }
  }
}
```

**cURL:**

```bash
curl 'https://api.durohub.com/graphql' \
  -H 'x-api-key: YOUR_API_TOKEN' \
  -H 'x-organization: @your-org-slug' \
  -H 'x-library: @your-org-slug/your-library-slug' \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "query { component { filter(input: { and: [{ status: { mapsTo: { eq: \"PRODUCTION\" } } }, { state: { eq: RELEASED } }] }) { edges { node { id name cpn } } totalCount } } }"
  }'
```

{% hint style="info" %}
**Required Headers:** All component queries require `x-organization` and `x-library` headers to scope your request to the correct library. Use either slugs (e.g., `@acme/main-library`) or UUIDs.
{% endhint %}

#### Example 2: Find Components by Category Type

Find all assemblies (not parts or documents):

```graphql
query FindAssemblies {
  component {
    filter(
      input: {
        and: [
          { category: { type: { eq: "ASSEMBLY" } } }
        ]
      }
    ) {
      edges {
        node {
          id
          name
          category {
            name
            type
          }
        }
      }
    }
  }
}
```

#### Example 3: Find Recently Modified Components by a Specific User

Find components that a specific user modified in the last 7 days:

```graphql
query RecentlyModifiedByUser {
  component {
    filter(
      input: {
        and: [
          { updatedBy: { id: { eq: "user-uuid-here" } } }
          { updatedAt: { after: "2024-12-25" } }
        ]
      }
    ) {
      edges {
        node {
          id
          name
          updatedAt
          updatedBy {
            firstName
            lastName
          }
        }
      }
    }
  }
}
```

#### Example 4: Using OR Logic

Find components that are either pinned OR have a specific label:

```graphql
query PinnedOrLabeled {
  component {
    filter(
      input: {
        or: [
          { isPinned: { eq: true } }
          { labels: { name: { eq: "Critical" } } }
        ]
      }
    ) {
      edges {
        node {
          id
          name
          labels {
            name
          }
        }
      }
    }
  }
}
```

#### Example 5: Complex Multi-Condition Query

Find components that match ALL of these criteria:

* Status is "Design" or "Development"
* Category type is "PART"
* Revision is greater than "A"
* Not bookmarked

```graphql
query ComplexFilter {
  component {
    filter(
      input: {
        and: [
          { status: { name: { in: ["Design", "Development"] } } }
          { category: { type: { eq: "PART" } } }
          { revision: { value: { gt: "A" } } }
          { isBookmarked: { eq: false } }
        ]
      }
    ) {
      edges {
        node {
          id
          name
          cpn
          status { name }
          category { name type }
          revision { value }
        }
      }
      totalCount
    }
  }
}
```

#### Example 6: Date Range Query

Find components created in Q1 2024:

```graphql
query Q1Components {
  component {
    filter(
      input: {
        and: [
          {
            createdAt: {
              rangeStart: "2024-01-01"
              rangeEnd: "2024-03-31"
            }
          }
        ]
      }
    ) {
      edges {
        node {
          id
          name
          createdAt
        }
      }
    }
  }
}
```

### Pagination and Sorting

Control result size and ordering:

```graphql
query PaginatedResults {
  component {
    filter(
      input: {
        and: [{ state: { eq: RELEASED } }]
        pagination: {
          first: 25
          after: "cursor-from-previous-page"
          orderBy: { field: CREATED_AT, direction: DESC }
        }
      }
    ) {
      edges {
        cursor
        node {
          id
          name
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount
    }
  }
}
```

**Available sort fields:** `CREATED_AT`, `UPDATED_AT`, `NAME`, `CPN`

**Directions:** `ASC`, `DESC`

**Default sort order:** When `orderBy` is omitted, results are sorted by `CREATED_AT DESC` (most recently created first). This default applies to `findAll`, `filter`, `componentGroups`, and `componentsInGroup`.

### Grouping

In addition to filtering and sorting, you can group components by category, lifecycle status, type, last-modified user, or label using the `componentGroups` query. Each group returns a capped preview of its components with `totalCount` and cursor pagination for loading more.

The advanced filter works with grouped queries too — pass `advancedFilter` alongside `groupBy` to scope results before grouping.

See [Component Grouping](/advanced-topics/component-grouping) for the full guide.

***

## Part 2: AI-Powered Search

Skip the filter syntax entirely. Describe what you're looking for in plain English, and get components back directly.

### Single-Call Search (Recommended)

The `filterWithAI` endpoint is the simplest way to search with natural language—one query, components returned directly:

```graphql
query SearchWithAI {
  component {
    filterWithAI(input: {
      query: "resistors modified by Sarah in the last 30 days"
      limit: 50
    }) {
      # The components you asked for
      components {
        id
        name
        cpn
        status { name }
        category { name }
      }
      totalCount

      # What the AI understood
      interpretation
      success

      # If you need to paginate beyond the limit
      filterInput

      # Anything that wasn't found
      notFoundEntities { type name }
    }
  }
}
```

**That's it.** Natural language in, components out. No two-step process, no filter syntax to learn.

### Input Options

| Field     | Type   | Default        | Description                               |
| --------- | ------ | -------------- | ----------------------------------------- |
| `query`   | String | Required       | Natural language query (3-500 characters) |
| `limit`   | Int    | 20             | Max components to return (1-100)          |
| `orderBy` | Object | createdAt DESC | Sort field and direction                  |

### Response Structure

| Field              | Type    | Description                                           |
| ------------------ | ------- | ----------------------------------------------------- |
| `components`       | Array   | The matching components (up to `limit`)               |
| `totalCount`       | Int     | Total matches in the library (may exceed `limit`)     |
| `interpretation`   | String  | Human-readable summary of what was understood         |
| `success`          | Boolean | Whether the AI understood your query                  |
| `filterInput`      | JSON    | The generated filter (for pagination—see below)       |
| `filterValues`     | Array   | UI-friendly format with field/comparator/displayLabel |
| `matchMode`        | String  | `"all"` (AND) or `"any"` (OR)                         |
| `notFoundEntities` | Array?  | Entities mentioned but not found                      |
| `error`            | String? | Error message if generation failed                    |

{% hint style="success" %}
**Need more than 100 results?** Use the `filterInput` field (returned as JSON) and pass it to `component.filter` for cursor-based pagination.
{% endhint %}

### cURL Example

```bash
curl 'https://api.durohub.com/graphql' \
  -H 'x-api-key: YOUR_API_TOKEN' \
  -H 'x-organization: @your-org-slug' \
  -H 'x-library: @your-org-slug/your-library-slug' \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "query { component { filterWithAI(input: { query: \"parts in production status\", limit: 25 }) { components { id name cpn } totalCount interpretation success } } }"
  }'
```

### Example Response

**Query:** `"resistors modified by Sarah in the last 30 days"`

```json
{
  "data": {
    "component": {
      "filterWithAI": {
        "components": [
          { "id": "comp-1", "name": "10K Resistor", "cpn": "RES-001" },
          { "id": "comp-2", "name": "100K Resistor", "cpn": "RES-002" }
        ],
        "totalCount": 47,
        "interpretation": "Showing Resistors modified by Sarah Chen in the last 30 days",
        "success": true,
        "filterInput": {
          "and": [
            { "category": { "id": { "eq": "cat-resistors" } } },
            { "updatedBy": { "id": { "eq": "user-sarah" } } },
            { "updatedAt": { "after": "2024-12-10T00:00:00.000Z" } }
          ]
        },
        "matchMode": "all",
        "notFoundEntities": null
      }
    }
  }
}
```

### Handling Partial Matches

If the AI can't find something you mentioned, it tells you:

**Query:** `"resistors modified by John Smith"`

```json
{
  "components": [{ "id": "...", "name": "10K Resistor" }],
  "totalCount": 23,
  "interpretation": "Showing Resistors",
  "success": true,
  "notFoundEntities": [
    { "type": "user", "name": "John Smith" }
  ]
}
```

The query still runs with the filters it *could* resolve. Check `notFoundEntities` to see what was missing.

### Best Practices

{% hint style="success" %}
**Be specific with names.** Say "Design" not "designing" or "in design phase."
{% endhint %}

{% hint style="success" %}
**Use full names for users.** "Sarah Chen" works better than just "Sarah."
{% endhint %}

{% hint style="success" %}
**Combine concepts with AND/OR.** "resistors OR capacitors modified last week" → results in OR match mode.
{% endhint %}

{% hint style="warning" %}
**Check `notFoundEntities`.** If a user or status isn't found, the AI will tell you—you may have a typo or need to use a different name.
{% endhint %}

### Complete Python Example

```python
import requests

API_URL = "https://api.durohub.com/graphql"
HEADERS = {
    "x-api-key": "YOUR_API_TOKEN",
    "x-organization": "@acme",
    "x-library": "@acme/electronics",
    "Content-Type": "application/json"
}

def search_components(query: str, limit: int = 50):
    """Search components using natural language - single API call!"""

    graphql_query = """
    query SearchWithAI($query: String!, $limit: Int) {
      component {
        filterWithAI(input: { query: $query, limit: $limit }) {
          components {
            id
            name
            cpn
            status { name }
            category { name }
          }
          totalCount
          interpretation
          success
          notFoundEntities { type name }
          filterInput
        }
      }
    }
    """

    response = requests.post(API_URL, headers=HEADERS, json={
        "query": graphql_query,
        "variables": {"query": query, "limit": limit}
    })

    result = response.json()["data"]["component"]["filterWithAI"]

    print(f"AI interpretation: {result['interpretation']}")
    print(f"Found {result['totalCount']} components (showing {len(result['components'])})")

    if result.get("notFoundEntities"):
        print(f"Warning - not found: {result['notFoundEntities']}")

    return result["components"]


# Usage
if __name__ == "__main__":
    components = search_components("resistors modified by Sarah Chen in the last 30 days")
    for comp in components:
        print(f"  - {comp['cpn']}: {comp['name']}")
```

### Paginating Large Result Sets

If `totalCount` exceeds your `limit`, use the `filterInput` field to paginate with `component.filter`:

```python
def search_all_components(query: str):
    """Search with AI, then paginate through all results."""

    # First call: get components + the filter
    result = search_with_ai(query, limit=100)

    all_components = result["components"]
    filter_input = result["filterInput"]

    # If there are more results, paginate using component.filter
    if result["totalCount"] > len(all_components):
        cursor = None
        while True:
            page = fetch_page(filter_input, after=cursor)
            all_components.extend(page["components"])
            if not page["hasNextPage"]:
                break
            cursor = page["endCursor"]

    return all_components
```

### Two-Step Approach (Advanced)

If you're building a filter UI where users need to see and edit individual filter conditions before executing, use `buildFilterWithAI` instead:

```graphql
query BuildFilter {
  component {
    buildFilterWithAI(input: { query: "parts in design" }) {
      filterValues {
        field
        comparator
        value
        displayLabel
      }
      matchMode
      interpretation
      success
    }
  }
}
```

This returns structured `filterValues` that you can display as editable filter pills. Once the user confirms, pass the filter to `component.filter`.

***

## Part 3: Tips and Best Practices

### Performance Tips

1. **Use `and` over `or` when possible.** AND queries are generally faster as they narrow results.
2. **Filter by indexed fields first.** Status, category, and state are highly optimized.
3. **Paginate large results.** Use `first: 50` and cursor-based pagination rather than fetching everything.
4. **Be specific with dates.** `{ after: "2024-01-01" }` is faster than date ranges when you only need "recent" items.

### Common Patterns

#### "Recently modified by me"

```graphql
{
  and: [
    { updatedBy: { id: { eq: "YOUR_USER_ID" } } }
    { updatedAt: { after: "2024-12-01" } }
  ]
}
```

#### "All released parts except obsolete"

```graphql
{
  and: [
    { category: { type: { eq: "PART" } } }
    { state: { eq: RELEASED } }
    { status: { mapsTo: { neq: "OBSOLETE" } } }
  ]
}
```

#### "Components with any of these labels"

```graphql
{
  or: [
    { labels: { name: { eq: "Critical" } } }
    { labels: { name: { eq: "Urgent" } } }
    { labels: { name: { eq: "Review Needed" } } }
  ]
}
```

#### "Early revisions only"

```graphql
{
  and: [
    { revision: { value: { in: ["A", "B", "1", "2"] } } }
  ]
}
```

### Debugging Filters

1. **Start simple.** Test one condition at a time before combining.
2. **Check your field paths.** `status.name` vs `status.mapsTo` vs `status.id` target different things.
3. **Verify IDs exist.** If filtering by UUID, ensure the status/category/user actually exists in your library.
4. **Watch for empty results.** An overly specific `and` query might match nothing. Try loosening conditions.

***

## Complete API Reference

### AdvancedComponentFilterInput

```graphql
input AdvancedComponentFilterInput {
  """All conditions must match (AND logic)"""
  and: [ComponentFilterConditionInput!]

  """Any condition can match (OR logic)"""
  or: [ComponentFilterConditionInput!]

  """Pagination options"""
  pagination: PaginationInput
}
```

### ComponentFilterConditionInput

```graphql
input ComponentFilterConditionInput {
  status: StatusFilterInput
  category: ComponentCategoryFilterInput
  state: StateFilterInput
  revision: RevisionFilterInput
  createdBy: UserFilterInput
  updatedBy: UserFilterInput
  labels: LabelFilterInput
  isPinned: BooleanFilterInput
  isBookmarked: BooleanFilterInput
  createdAt: DateFilterInput
  updatedAt: DateFilterInput
}
```

### StatusFilterInput

```graphql
input StatusFilterInput {
  """Filter by status UUID"""
  id: StringOperatorsInput

  """Filter by custom status name"""
  name: StringOperatorsInput

  """Filter by status type: DESIGN, DEVELOPMENT, PRODUCTION, MAINTENANCE, OBSOLETE"""
  mapsTo: StringOperatorsInput
}
```

### ComponentCategoryFilterInput

```graphql
input ComponentCategoryFilterInput {
  """Filter by category UUID"""
  id: StringOperatorsInput

  """Filter by category name"""
  name: StringOperatorsInput

  """Filter by category type: ASSEMBLY, PART, DOCUMENT"""
  type: StringOperatorsInput
}
```

### LabelFilterInput

```graphql
input LabelFilterInput {
  """Filter by label name (the unique identifier for labels)"""
  name: StringOperatorsInput
}
```

{% hint style="info" %}
**Labels filter by name, not ID.** Unlike other entities, labels are matched using their `name` field (e.g., `"Critical"`, `"High Priority"`). This is the canonical identifier for labels in the filter system.
{% endhint %}

### StringOperatorsInput

```graphql
input StringOperatorsInput {
  eq: String
  neq: String
  in: [String!]
  notIn: [String!]
  contains: String
  notContains: String
}
```

### DateFilterInput

```graphql
input DateFilterInput {
  """Date is before this value (exclusive)"""
  before: String

  """Date is after this value (exclusive)"""
  after: String

  """Date equals this value (same day)"""
  on: String

  """Start of date range (inclusive)"""
  rangeStart: String

  """End of date range (inclusive)"""
  rangeEnd: String
}
```

### RevisionOperatorsInput

```graphql
input RevisionOperatorsInput {
  eq: String
  neq: String
  in: [String!]
  notIn: [String!]
  lt: String
  gt: String
}
```

### BooleanFilterInput

```graphql
input BooleanFilterInput {
  eq: Boolean
}
```

### FilterWithAiInput (Recommended)

```graphql
input FilterWithAiInput {
  """Natural language query (3-500 characters)"""
  query: String!

  """Max components to return (default: 20, max: 100)"""
  limit: Int

  """Sort order for results"""
  orderBy: ComponentOrderInput
}
```

### FilterWithAiOutput

```graphql
type FilterWithAiOutput {
  """The matching components"""
  components: [Component!]!

  """Total count of matching components in the library"""
  totalCount: Int!

  """Match mode: 'all' (AND) or 'any' (OR)"""
  matchMode: String!

  """Whether the AI understood your query"""
  success: Boolean!

  """Human-readable interpretation of the filters"""
  interpretation: String

  """UI-friendly filter format"""
  filterValues: [FilterValueOutput!]!

  """The generated filter as JSON (for pagination with component.filter)"""
  filterInput: JSON

  """Entities mentioned but not found in the library"""
  notFoundEntities: [NotFoundEntity!]

  """Error message if generation failed"""
  error: String
}
```

### BuildAiFilterInput (Two-Step Approach)

```graphql
input BuildAiFilterInput {
  """Natural language query (3-500 characters)"""
  query: String!
}
```

### AiFilterResult

```graphql
type AiFilterResult {
  """Ready-to-use filter - pass directly to component.filter(input:)"""
  filterInput: AdvancedComponentFilterOutput

  """UI-friendly filter format with field/comparator/value/displayLabel"""
  filterValues: [FilterValueOutput!]!

  """Match mode: 'all' (AND) or 'any' (OR)"""
  matchMode: String!

  """Whether filter generation succeeded"""
  success: Boolean!

  """Error message if generation failed"""
  error: String

  """Entities mentioned but not found in the library"""
  notFoundEntities: [NotFoundEntity!]

  """Human-readable interpretation of the filters"""
  interpretation: String
}
```

### AdvancedComponentFilterOutput

The `filterInput` field returns this type, which mirrors `AdvancedComponentFilterInput`:

```graphql
type AdvancedComponentFilterOutput {
  and: [ComponentFilterConditionOutput!]
  or: [ComponentFilterConditionOutput!]
}

type ComponentFilterConditionOutput {
  status: StatusFilterOutput
  category: CategoryFilterOutput
  state: StateFilterOutput
  revision: RevisionFilterOutput
  createdBy: UserFilterOutput
  updatedBy: UserFilterOutput
  labels: LabelFilterOutput
  isPinned: BooleanFilterOutput
  isBookmarked: BooleanFilterOutput
  createdAt: DateFilterOutput
  updatedAt: DateFilterOutput
}
```

***

## Next Steps

* Learn about controlling access with [Role-Based Access Control](/advanced-topics/rbac)
* Set up [Webhooks](/advanced-topics/webhooks) to react to component changes in real-time
* Explore [Change Order Workflows](/library-configuration/change-order-workflows) for managing engineering changes


# Component Grouping

Group components by category, lifecycle status, type, modified-by user, or label — server-side, in a single GraphQL call. Each group returns a capped preview of its components with cursor-based pagination for loading more.

## Quick start

```graphql
query GroupByCategory {
  component {
    componentGroups(
      groupBy: CATEGORY
      pagination: { first: 10 }
    ) {
      edges {
        node {
          key
          label
          totalCount
          components {
            edges {
              node {
                id
                name
                identifier { displayValue }
              }
            }
            pageInfo {
              hasNextPage
            }
          }
        }
      }
      totalCount
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
}
```

This returns up to 10 groups, each containing a preview of up to 50 components sorted by the default order (`CREATED_AT DESC`). Groups with more than 50 components signal overflow via `components.pageInfo.hasNextPage`, and the remaining items can be fetched with the [`componentsInGroup`](#loading-more-components-in-a-group) query.

***

## When to use grouping

Use `componentGroups` when your integration needs to:

* **Render a grouped UI** — category buckets, status lanes, label-based views
* **Count items per dimension** — each group carries a `totalCount` without a separate aggregation query
* **Combine grouping with filtering** — pass `filter` or `advancedFilter` alongside `groupBy` to scope results before grouping

For flat, ungrouped lists, continue using `component.findAll` or `component.filter`.

***

## Grouping dimensions

The `ComponentGroupField` enum defines the five available dimensions:

| Value              | Groups by               | Group key                         | Group label                                |
| ------------------ | ----------------------- | --------------------------------- | ------------------------------------------ |
| `CATEGORY`         | Component category      | Category UUID                     | Category name (e.g., "Capacitor")          |
| `LIFECYCLE_STATUS` | Custom lifecycle status | Status UUID                       | Status name (e.g., "Design", "Production") |
| `TYPE`             | Category type           | `ASSEMBLY`, `PART`, or `DOCUMENT` | Same as key, title-cased                   |
| `MODIFIED_BY`      | Last-modified user      | User UUID                         | User display name                          |
| `LABEL`            | Attached labels         | Label UUID                        | Label display name                         |

### Sentinel groups for null values

Components that lack a value for the grouping dimension are collected into synthetic "null-bucket" groups with sentinel keys:

| Dimension          | Sentinel key         | Label          |
| ------------------ | -------------------- | -------------- |
| `CATEGORY`         | `__uncategorized__`  | Uncategorized  |
| `LIFECYCLE_STATUS` | `__unknown-status__` | Unknown status |
| `TYPE`             | `__unknown-type__`   | Unknown type   |
| `MODIFIED_BY`      | `__unknown-user__`   | Unknown user   |
| `LABEL`            | `__no-label__`       | No label       |

Sentinel groups always sort last in the response. Your integration should handle these keys gracefully — they are stable strings, not UUIDs.

{% hint style="info" %}
**Labels are many-to-many.** A component with multiple labels appears in every label group it belongs to. The component's `totalCount` across label groups may exceed the library's total component count.
{% endhint %}

***

## Pagination across groups

The outer `componentGroups` connection uses offset-based cursor pagination to page through groups:

```graphql
query NextGroupPage {
  component {
    componentGroups(
      groupBy: LIFECYCLE_STATUS
      pagination: {
        first: 5
        after: "eyJvZmZzZXQiOjV9"  # endCursor from previous page
      }
    ) {
      edges {
        node { key label totalCount }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount   # true total across all pages
    }
  }
}
```

`totalCount` on the outer connection is the total number of groups matching the active filters, not the number returned on this page. Use it for "Showing 1-5 of 12 groups" labels.

***

## Loading more components in a group

Each group's `components` connection is capped at 50 items. When `components.pageInfo.hasNextPage` is `true`, use `componentsInGroup` to fetch the next batch:

```graphql
query LoadMoreInGroup {
  component {
    componentsInGroup(
      groupBy: CATEGORY
      groupKey: "550e8400-e29b-41d4-a716-446655440000"
      pagination: {
        first: 50
        after: "cursor-from-last-edge"
        orderBy: { field: NAME, direction: ASC }
      }
    ) {
      edges {
        cursor
        node {
          id
          name
          identifier { displayValue }
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount
    }
  }
}
```

The `groupKey` matches the `key` field from the parent group. For sentinel groups, use the sentinel string directly (e.g., `"__uncategorized__"`).

`componentsInGroup` uses keyset cursor pagination — pass the last edge's `cursor` as `after` to advance. The cursor encodes the sort-field value and the component ID, so it's tied to the `orderBy` you specify. Changing the sort field or direction invalidates previous cursors.

***

## Filtering + grouping

Both `filter` (simple) and `advancedFilter` (AND/OR logic) work with `componentGroups`. When both are provided, `advancedFilter` takes priority and the simple filter is ignored.

### Simple filter

```graphql
query GroupReleasedParts {
  component {
    componentGroups(
      groupBy: CATEGORY
      filter: { categoryType: PART, isArchived: false }
    ) {
      edges {
        node {
          key
          label
          totalCount
        }
      }
    }
  }
}
```

### Advanced filter

```graphql
query GroupByStatusFilteredByLabel {
  component {
    componentGroups(
      groupBy: LIFECYCLE_STATUS
      advancedFilter: {
        and: [
          { labels: { name: { eq: "High Priority" } } }
          { category: { type: { eq: "PART" } } }
        ]
      }
    ) {
      edges {
        node {
          key
          label
          totalCount
        }
      }
    }
  }
}
```

See [Searching and Filtering](/advanced-topics/searching-and-filtering) for the full `AdvancedComponentFilterInput` reference.

***

## Sorting within groups

Control how components are ordered inside each group using `pagination.orderBy`:

```graphql
query GroupSortedByName {
  component {
    componentGroups(
      groupBy: LABEL
      pagination: {
        first: 20
        orderBy: { field: NAME, direction: ASC }
      }
    ) {
      edges {
        node {
          key
          label
          components {
            edges {
              node { id name }
            }
          }
        }
      }
    }
  }
}
```

**Available sort fields:**

| Field        | Description                                      |
| ------------ | ------------------------------------------------ |
| `NAME`       | Component name (alphabetical)                    |
| `CPN`        | Component Part Number (identifier display value) |
| `CREATED_AT` | Creation timestamp                               |
| `UPDATED_AT` | Last-modified timestamp                          |

**Directions:** `ASC`, `DESC`

**Default:** When `orderBy` is omitted, components are sorted by `CREATED_AT DESC` (most recently created first). This matches the default behavior of `component.findAll` and `component.filter`.

{% hint style="warning" %}
The `orderBy` applies to both the initial per-group preview AND subsequent `componentsInGroup` calls. If you change the sort when loading more, the cursor from the previous page becomes invalid — start a fresh pagination sequence.
{% endhint %}

***

## Complete API reference

### ComponentGroupField

```graphql
"""Fields that can be used to group components via the componentGroups query."""
enum ComponentGroupField {
  CATEGORY
  LIFECYCLE_STATUS
  TYPE
  MODIFIED_BY
  LABEL
}
```

### componentGroups field

```graphql
type ComponentQuery {
  componentGroups(
    groupBy: ComponentGroupField!
    filter: ComponentFilterInput
    advancedFilter: AdvancedComponentFilterInput
    pagination: PaginationInput
  ): ComponentGroupConnection!
}
```

### ComponentGroupConnection

```graphql
type ComponentGroupConnection {
  edges: [ComponentGroupEdge!]!
  pageInfo: PageInfo!
  """Total number of distinct groups matching the active filters, across all pages."""
  totalCount: Int!
}

type ComponentGroupEdge {
  node: ComponentGroup!
  cursor: String!
}
```

### ComponentGroup

```graphql
type ComponentGroup {
  """Stable server-defined group identifier (entity UUID or sentinel key)."""
  key: ID!

  """Human-readable group name."""
  label: String!

  """Total components in this group matching the active filters."""
  totalCount: Int!

  """Paginated component preview, capped at 50 items per group."""
  components: ComponentConnection!
}
```

### componentsInGroup field

```graphql
type ComponentQuery {
  componentsInGroup(
    groupBy: ComponentGroupField!
    groupKey: String!
    filter: ComponentFilterInput
    advancedFilter: AdvancedComponentFilterInput
    pagination: PaginationInput
  ): ComponentConnection!
}
```

### PaginationInput

```graphql
input PaginationInput {
  """Number of items to return (max 100)."""
  first: Int

  """Cursor from a previous page's endCursor."""
  after: String

  """Sort order for results."""
  orderBy: GenericOrderInput
}

input GenericOrderInput {
  field: String!
  direction: OrderDirection!
}

enum OrderDirection {
  ASC
  DESC
}
```

***

## Next steps

* Learn about [Searching and Filtering](/advanced-topics/searching-and-filtering) for the full filter reference
* Explore [Components](/core-concepts/components) for create, update, and assembly operations
* Set up [Webhooks](/advanced-topics/webhooks) to react to component changes in real-time


# Search Query Syntax

The Duro search bar supports a structured `key:value` query syntax that lets you build precise filters by typing. Queries are parsed into filter pills that you can further refine visually.

{% hint style="info" %}
This page covers the **manual search syntax** (Normal mode). For AI-powered natural language search, toggle "AI Mode On" in the search bar and type freely. For the programmatic API, see [Searching and Filtering](/advanced-topics/searching-and-filtering).
{% endhint %}

## How It Works

1. Type a query like `type:part status:design` in the search bar
2. Press **Enter**
3. Your query is parsed into filter pills on the components page
4. Results update immediately

Multiple filters separated by spaces combine with **AND** logic. You can switch to **Any** (OR) mode using the toggle in the filter pill bar.

***

## Quick Reference

| Key          | Aliases       | Description          | Example                 |
| ------------ | ------------- | -------------------- | ----------------------- |
| `type`       |               | Component type       | `type:part`             |
| `status`     |               | Status name          | `status:design`         |
| `state`      |               | Released or modified | `state:released`        |
| `category`   |               | Category name        | `category:capacitor`    |
| `label`      | `labels`      | Label / tag name     | `labels:"Needs Review"` |
| `cpn`        |               | Part number (CPN)    | `cpn:212-00001`         |
| `name`       |               | Component name       | `name:"Mountain Bike"`  |
| `desc`       | `description` | Description text     | `desc:ceramic`          |
| `created`    | `createdAt`   | Creation date        | `created:>2025-01-01`   |
| `modified`   | `updatedAt`   | Last modified date   | `modified:>2025-01-01`  |
| `createdBy`  |               | Creator name         | `createdBy:dustin`      |
| `modifiedBy` |               | Last modifier name   | `modifiedBy:rachel`     |
| `rev`        | `revision`    | Revision value       | `rev:>A`                |
| `pinned`     |               | Pinned status        | `pinned:true`           |
| `bookmarked` |               | Bookmarked status    | `bookmarked:true`       |
| `attr:`      |               | Attribute value      | `attr:capacitance:>10`  |

Free text without a key prefix searches across name, CPN, and description simultaneously.

Text values support the `*` wildcard for pattern matching (e.g. `cpn:401-*`, `name:*widget*`, or the free-text `401-*`). See [Wildcards](#wildcards).

***

## Operators

| Syntax           | Meaning               | Example                          |
| ---------------- | --------------------- | -------------------------------- |
| `key:value`      | equals                | `status:design`                  |
| `key!:value`     | not equals            | `status!:design`                 |
| `key:val1,val2`  | any of (OR)           | `status:design,prototype`        |
| `key!:val1,val2` | none of               | `modifiedBy!:dustin,yuri`        |
| `key:>value`     | greater than / after  | `created:>2025-01-01`            |
| `key:<value`     | less than / before    | `rev:<C`                         |
| `key:>=value`    | greater than or equal | `attr:weight:>=100`              |
| `key:<=value`    | less than or equal    | `attr:capacitance:<=10`          |
| `key:val1..val2` | range (inclusive)     | `created:2025-01-01..2025-06-01` |

### Quoting

Use double quotes for values that contain spaces:

```
labels:"Needs Review"
name:"Mountain Bike Frame"
```

Commas and colons inside quotes are treated as literal characters:

```
labels:"A, B"        (one label named "A, B", not two labels)
name:"Part: Rev A"   (colon is part of the name)
```

***

## Wildcards

Use `*` as a wildcard to match **zero or more characters** at any position. Wildcards work in free-text searches and in the `name`, `cpn`, `desc`, and `label` keys.

| Query           | Matches                                                         |
| --------------- | --------------------------------------------------------------- |
| `401-*`         | `401-00001`, `401-00002`, `401-WIDGET` (starts with "401-")     |
| `*-00001`       | `ABC-00001`, `401-00001`, `XYZ-00001` (ends with "-00001")      |
| `*401*`         | `ABC401XYZ`, `401-Widget`, `PREFIX-401-SUFFIX` (contains "401") |
| `401-*01`       | starts with "401-" and ends with "01"                           |
| `*401*00*`      | contains "401" then "00", in that order                         |
| `cpn:401-*`     | components whose CPN starts with "401-"                         |
| `name:*widget*` | components with "widget" anywhere in the name                   |
| `labels:*-Beta` | components with a label ending in "-Beta"                       |

### Position

Wildcards can appear anywhere — at the start (prefix), end (suffix), in the middle (internal), or several at once:

```
*00001          (suffix — ends with "00001")
401-*           (prefix — starts with "401-")
401-*01         (internal — starts with "401-", ends with "01")
*401*00*        (multiple)
```

### Wildcards vs. exact match

For the text keys, a value **without** a `*` is matched literally:

* `name:widget` — matches the exact name "widget" (case-insensitive)
* `name:*widget*` — matches any name that **contains** "widget"
* `name:widget*` — matches any name that **starts with** "widget"

`desc:` and free text always search within the content, so `desc:ceramic` already behaves like a "contains" search.

### Escaping

To match a literal asterisk, escape it with a backslash:

```
Part\*Model     (matches the literal name "Part*Model")
```

### Notes

* Consecutive wildcards collapse to one: `401-**01` is treated as `401-*01`.
* Quote values that contain spaces, keeping the wildcards inside the quotes: `name:"*mountain bike*"`.
* Leading wildcards (`*00001`) are supported. They scan more broadly, so they can be slower on large libraries.

***

## Keys in Detail

### type

Filter by component type. Values: `part`, `assembly`, `document`.

```
type:part
type:assembly
type:document
type:part,assembly       (parts OR assemblies)
```

### status

Filter by the component's custom status name. Uses the exact status names configured in your library.

```
status:design
status:production
status!:obsolete         (not in obsolete status)
status:design,prototype  (in design OR prototype)
```

### state

Filter by release state. Values: `released`, `modified`.

```
state:released
state:modified
```

### category

Filter by category name. Uses the exact category names configured in your library.

```
category:capacitor
category:resistor
category!:connector       (not connectors)
category:diode,transistor (diodes OR transistors)
```

### label / labels

Filter by label (tag) name. Uses the exact label names in your library. Supports `*` wildcards to match label names by pattern (see [Wildcards](#wildcards)).

```
label:urgent
labels:"Needs Review"
labels!:deprecated
labels:*-Beta            (labels ending in "-Beta")
```

### cpn

Filter by component part number (CPN). Matches the displayed CPN value. Supports `*` wildcards (see [Wildcards](#wildcards)).

```
cpn:212-00001
cpn:RES-001
cpn:401-*                (CPNs starting with "401-")
cpn:*-00001              (CPNs ending with "-00001")
```

### name

Filter by component name. Without a wildcard, the match is **exact** (case-insensitive). Use `*` wildcards for partial matching (see [Wildcards](#wildcards)).

```
name:LM7805              (exact name "LM7805")
name:"Mountain Bike"     (exact name "Mountain Bike" — quotes for spaces)
name:*widget*            (name contains "widget")
name:LM78*               (name starts with "LM78")
name:"*mountain bike*"   (contains "mountain bike"; wildcards stay inside quotes)
```

### desc / description

Search within component descriptions. Uses full-text search with word stemming (e.g., "capacitor" matches "capacitors"). Also supports `*` wildcards for literal pattern matching (see [Wildcards](#wildcards)).

```
desc:ceramic
description:"high voltage"
desc!:obsolete            (description does not contain "obsolete")
desc:*ceramic*            (description contains the literal string "ceramic")
```

### created / modified

Filter by creation or modification date. Supports comparison operators and ranges.

```
created:>2025-01-01           (created after Jan 1, 2025)
modified:<2025-06-01          (modified before Jun 1, 2025)
created:2025-01-01..2025-06-01  (created between Jan 1 and Jun 1)
modified:>2025-03-01          (modified after Mar 1, 2025)
```

### createdBy / modifiedBy

Filter by the user who created or last modified the component. Matches against user names in your library.

```
createdBy:dustin
modifiedBy:rachel
modifiedBy!:dustin,yuri   (not modified by dustin or yuri)
```

### rev / revision

Filter by revision value. Supports comparison operators for ordering.

```
rev:A
rev:>B           (revisions after B)
rev:<5           (revisions before 5)
rev:A..D         (revisions A through D)
```

### pinned / bookmarked

Filter by pin or bookmark status.

```
pinned:true
pinned:false
bookmarked:true
```

### attr: (Attributes)

Filter by user-defined attribute values. The `attr:` prefix is required to distinguish from built-in keys. Attribute names are matched case-insensitively against your library's configured attributes.

```
attr:capacitance:>10
attr:color:black
attr:weight:>=100
attr:material:steel
attr:cost:<50
```

{% hint style="info" %}
Attribute filters require the component to **have** the attribute. For example, `attr:color!:black` returns components that have a color attribute but where the value is not black. Components without a color attribute are excluded.
{% endhint %}

***

## Free Text Search

Any text without a `key:` prefix is treated as a free-text search across component name, CPN, and description.

```
mountain bike            (searches name, CPN, and description)
LM7805                   (finds by name or CPN match)
ceramic                  (finds in name or description)
```

Free text also supports `*` wildcards, applied across name, CPN, and description (see [Wildcards](#wildcards)):

```
401-*                    (name/CPN/description starting with "401-")
*00001                   (ending with "00001")
*401*                    (containing "401")
```

Free text can be combined with key:value filters:

```
ceramic type:part        (free text "ceramic" AND type is part)
```

***

## Combining Filters

Multiple space-separated filters combine with **AND** logic:

```
type:part status:design modifiedBy:dustin created:>2025-01-01
```

This finds **parts** in **design** status, modified by **dustin**, created **after Jan 1, 2025** — all conditions must match.

After parsing, you can switch to **Any** (OR) mode using the All/Any toggle in the filter pill bar.

***

## Common Scenarios

### Find all parts in a specific status

```
type:part status:design
```

### Find components not modified by a specific user

```
modifiedBy!:john
```

### Find released resistors and capacitors

```
state:released category:resistor,capacitor
```

### Find components modified in the last quarter

```
modified:>2025-01-01
```

### Find components by date range

```
created:2025-01-01..2025-03-31
```

### Find components by description keyword

```
desc:automotive
```

### Find all components in a CPN series (wildcard)

```
cpn:401-*
```

### Find components with a name pattern (wildcard)

```
name:*bracket*
```

### Find parts where an attribute exceeds a threshold

```
type:part attr:capacitance:>100
```

### Find components where color is not black

```
attr:color!:black
```

### Exclude multiple users from results

```
modifiedBy!:dustin,yuri
```

### Find components with a specific revision or higher

```
rev:>B type:part
```

### Find pinned favorites in production

```
pinned:true status:production
```

### Combine free text with structured filters

```
ceramic type:part status:design
```

***

## Error Handling

If a key is unrecognized, the search bar highlights the problematic segment and suggests a correction. For example:

* `weight:100` — suggests `attr:weight:100` (attribute prefix required)
* `foobar:xyz` — shows "Unknown key" error

Partially valid queries still work. If 3 of 4 segments parse successfully, the 3 valid filters are applied and the 1 error is shown.

***

## API Equivalent

The search bar syntax maps to the `buildFilterFromQuery` GraphQL query. API consumers can use this directly:

```graphql
query {
  component {
    buildFilterFromQuery(input: { query: "type:part status:design" }) {
      filterValues {
        field
        comparator
        value
        displayLabel
      }
      filterInput {
        and {
          status { id { eq } }
          category { type { eq } }
        }
      }
      success
      errors {
        segment
        message
        suggestion
      }
    }
  }
}
```

Or execute the search in one call:

```graphql
query {
  component {
    filterWithQuery(input: { query: "type:part status:design", limit: 50 }) {
      components {
        id
        name
      }
      totalCount
      pageInfo {
        hasNextPage
        endCursor
        resultsReturnedCount
      }
      interpretation
    }
  }
}
```

To paginate through results, pass the `endCursor` from the previous page as `after`:

```graphql
query {
  component {
    filterWithQuery(input: {
      query: "type:part status:design"
      limit: 50
      after: "eyJ2IjoiMjAyNi0wMy0yM1..."
    }) {
      components { id name }
      pageInfo { hasNextPage endCursor }
    }
  }
}
```

{% hint style="success" %}
The `filterWithQuery` API accepts the same syntax as the search bar, making it easy to build integrations that mirror the web app's search experience.
{% endhint %}

***

## Next Steps

* [Searching and Filtering API Reference](/advanced-topics/searching-and-filtering) — programmatic filtering with the Advanced Filter API and AI search
* [Components](/core-concepts/components) — component data model and CRUD operations
* [Authentication](/getting-started/authentication) — setting up API access


# Role-Based Access Control

Control who can do what in your Duro organization with fine-grained, permission-based access control.

## Overview

Duro's RBAC system provides granular control over user access at both the organization and library levels. This allows you to:

* Assign broad organization-wide roles for internal team members
* Grant specific library-level access for external partners or contractors
* Override organization roles for specific libraries when needed
* Create custom roles tailored to your team's workflow

{% hint style="info" %}
RBAC replaces the legacy role enum system with a flexible, permission-based model that supports both system-defined and custom roles.
{% endhint %}

## Core Concepts

### Role Types: A 2x2 Matrix

Duro's RBAC system has two dimensions for understanding roles:

|                 | **Organization Scope**                     | **Library Scope**                        |
| --------------- | ------------------------------------------ | ---------------------------------------- |
| **System Role** | Admin, Editor, Reviewer, Viewer, Supplier  | Admin, Editor, Reviewer, Viewer          |
| **Custom Role** | User-created roles with custom permissions | Automatically created from org templates |

**Scope** determines where the role applies:

* **Organization roles** grant access across all libraries in your organization
* **Library roles** grant access to a specific library only

**Type** determines who manages the role:

* **System roles** are created by Duro and cannot be modified or deleted
* **Custom roles** are created by your organization and fully editable

### Hierarchical Role Resolution

When a user accesses a library, Duro determines their effective permissions using this resolution order:

```
Effective Role = Library Role ?? Organization Role ?? No Access
```

1. **If the user has a library-specific role** → Use that role (takes precedence)
2. **Else if the user has an organization role** → Use that role (applies to all libraries)
3. **Else** → Deny access

Roles reach a user from two sources: **directly** (assigned to the user) and **through teams** (assigned to any team the user belongs to). Each source resolves independently using the precedence above, and the user's effective role in a library is the **union** — the most permissive role any source grants. See [Team-Based Access Control](#team-based-access-control) for the full model.

This enables powerful access patterns:

| Scenario                        | Direct Org Role | Direct Library Role         | Team Roles                | Effective Access                                                          |
| ------------------------------- | --------------- | --------------------------- | ------------------------- | ------------------------------------------------------------------------- |
| Internal employee               | Editor          | —                           | —                         | Editor access everywhere                                                  |
| Employee with elevated access   | Editor          | Admin (Project X)           | —                         | Admin on Project X, Editor elsewhere                                      |
| Employee with restricted access | Admin           | Viewer (Sensitive)          | —                         | Viewer on Sensitive, Admin elsewhere                                      |
| External partner                | —               | Supplier (shared libraries) | —                         | Supplier on specific libraries only                                       |
| External contractor via team    | —               | —                           | Editor (Engineering team) | Editor wherever the team grants access — access via team membership alone |
| Team-elevated employee          | Viewer          | —                           | Editor (Leads team)       | Editor everywhere (most permissive of direct Viewer and team Editor)      |

Here's how this looks in practice—a user with an Editor organization role and a Reviewer override for a specific library:

![Edit User Access showing organization role with library override](/files/bfLq8BNQjJgV8k49mS5n)

### Team-Based Access Control

Access in Duro is not limited to individuals. A **team** carries access the same way a user does: it has one organization-scoped role plus any number of per-library role overrides — full parity with the user model. Assigning access to a team, then adding people to that team, is how you grant consistent access to a group without editing each member individually.

#### How team roles combine with direct roles

A user's effective access is the **union** of two things:

1. Their **direct roles** (assigned to the user)
2. The roles granted by **every team they belong to**

Each source resolves to a library role using the same `Library Role ?? Organization Role` precedence. The user's effective role in a given library is then the **most permissive** role any of those sources produces.

Two consequences follow:

* **Team membership alone can grant organization access.** A user with no direct organization role still gets access if they belong to a team that has one. This is the common pattern for external contractors — no direct role, added to a team that grants what they need.
* **Teams only ever add access.** Because effective access is a union, a team can elevate a user but never reduce what their direct roles already grant.

{% hint style="info" %}
A restrictive library override limits only the user's **direct** roles. If the user also belongs to a team that grants broader access to that same library, the team's role can still elevate them — the union always resolves to the most permissive role. Keep this in mind when using an override to restrict a specific user.
{% endhint %}

#### Where teams come from

Teams can be managed two ways:

* **Directly in Duro** — create teams, add members, and assign roles through the Duro UI.
* **Provisioned from your IdP via SCIM** — a SCIM Group maps to a Duro team, and group membership drives team membership automatically. See [SCIM Provisioning](/advanced-topics/scim-provisioning).

{% hint style="warning" %}
**Roles on a team are always assigned in Duro, never by the IdP.** Even when a team's membership is provisioned via SCIM, its organization role and library overrides are set by a Duro administrator. Duro does not consume the SCIM `roles` attribute.
{% endhint %}

### Admin Bypass

Roles marked as `isAdmin: true` bypass all permission checks. Admin users automatically have full access without needing individual permissions granted.

## System Roles

System roles are created by Duro and provide baseline access levels for common use cases. They cannot be modified or deleted.

### Organization-Level System Roles

| Role           | Admin? | Best For                              | Description                                                                                                                 |
| -------------- | ------ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **Site Admin** | Yes    | Organization creators                 | Full system access. Automatically assigned to the user who created the organization.                                        |
| **Admin**      | Yes    | IT administrators, managers           | Organization administrator with full access to all settings and libraries.                                                  |
| **Editor**     | No     | Engineers, designers                  | Standard member who can create and edit components, assemblies, and change orders. Can participate in the full CO workflow. |
| **Reviewer**   | No     | Quality assurance, external reviewers | Can view content and approve/reject/release change orders but cannot create or modify components.                           |
| **Viewer**     | No     | Stakeholders, observers               | Read-only access to view components, assemblies, and change orders.                                                         |
| **Supplier**   | No     | External suppliers, vendors           | Limited read-only access to shared content. Ideal for supply chain partners.                                                |

![Roles & Permissions overview showing system roles with capability breakdowns](/files/trjS6Rd9U6xViHs87poq)

### Library-Level System Roles

Library roles mirror organization roles but apply to a single library:

| Role         | Admin? | Description                                                    |
| ------------ | ------ | -------------------------------------------------------------- |
| **Admin**    | Yes    | Full access to this library including settings and permissions |
| **Editor**   | No     | Can create, edit, and manage content in this library           |
| **Reviewer** | No     | Can view and participate in change order workflow              |
| **Viewer**   | No     | Read-only access to this library                               |

{% hint style="info" %}
Library roles are typically created automatically when you assign a user to a specific library. You don't need to manually create them.
{% endhint %}

## Permission Reference

Permissions follow the naming convention `{resource}.{action}` or `{resource}.{sub-resource}.{action}`.

![Permission categories in the Create Role interface](/files/8C9VfaRZ1ciixSovEVMe)

### Content Management

| Permission                   | UI Label                 | Description                                                                      | Use Case                                                                                     |
| ---------------------------- | ------------------------ | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `components.create`          | Create Components        | Create new components in the library                                             | Grant to engineers who design new parts                                                      |
| `components.read`            | View Components          | View component details, specifications, and history                              | Required for anyone who needs visibility into the parts database                             |
| `components.update`          | Edit Components          | Modify component properties and metadata                                         | Grant to engineers maintaining part data                                                     |
| `components.delete`          | Delete Components        | Permanently remove components                                                    | Restrict to administrators; deletion is irreversible                                         |
| `components.revision.create` | Create Revisions         | Create new revisions of existing components                                      | Grant to engineers working on part iterations                                                |
| `identifier.override`        | Override Identifiers     | Override element values when generating an identifier from a CPN scheme          | Granted to Editors by default; the library's CPN scheme must also allow overrides            |
| `identifier.freeform`        | Use Freeform Identifiers | Assign a freeform identifier instead of one generated from the structured scheme | Granted to Editors by default; the library's CPN scheme must also allow freeform identifiers |
| `assemblies.create`          | Create Assemblies        | Create new assemblies and BOMs                                                   | Grant to engineers building product structures                                               |
| `assemblies.read`            | View Assemblies          | View assembly structures and BOM data                                            | Required for manufacturing and procurement visibility                                        |
| `assemblies.update`          | Edit Assemblies          | Modify assembly structure and BOM                                                | Grant to engineers maintaining product structures                                            |
| `assemblies.delete`          | Delete Assemblies        | Permanently remove assemblies                                                    | Restrict to administrators                                                                   |
| `library_pins.read`          | View Pins                | View pinned components in a library                                              | Grant for quick access to frequently used components                                         |
| `library_pins.create`        | Pin Components           | Pin components to a library for quick access                                     | Grant to users who curate commonly-used parts                                                |
| `library_pins.delete`        | Unpin Components         | Remove component pins                                                            | Grant alongside pin creation                                                                 |
| `labels.create`              | Create Labels            | Create new labels and assign them to components                                  | Grant to users who organize and categorize components                                        |
| `labels.read`                | View Labels              | View labels and their assignments                                                | Required for anyone who needs to see component categorization                                |
| `labels.update`              | Edit Labels              | Modify label names, colors, and descriptions                                     | Grant to users who maintain label taxonomy                                                   |
| `labels.delete`              | Delete Labels            | Remove labels from the system                                                    | Restrict to administrators; affects all components using the label                           |

### Change Order Workflow

| Permission                       | UI Label          | Description                                  | Use Case                                                  |
| -------------------------------- | ----------------- | -------------------------------------------- | --------------------------------------------------------- |
| `change_orders.create`           | Create COs        | Create new change orders                     | Grant to engineers initiating design changes              |
| `change_orders.read`             | View COs          | View change order details and status         | Required for anyone tracking engineering changes          |
| `change_orders.update`           | Edit COs          | Modify change order details                  | Grant to CO owners and administrators                     |
| `change_orders.delete`           | Delete COs        | Archive or delete change orders              | Restrict to administrators; use sparingly                 |
| `change_orders.submit`           | Submit for Review | Submit a change order for approval           | Grant to engineers who create change orders               |
| `change_orders.approve`          | Approve COs       | Approve change orders in the workflow        | Grant to reviewers, leads, and quality personnel          |
| `change_orders.reject`           | Reject COs        | Reject change orders and return for revision | Grant alongside approve permission                        |
| `change_orders.release`          | Release COs       | Close and release approved change orders     | Grant to release managers or quality leads                |
| `change_orders.withdraw`         | Withdraw COs      | Withdraw a submitted change order            | Grant to CO creators and administrators                   |
| `change_orders.reset`            | Reset COs         | Reset a change order back to draft status    | Restrict to administrators; used for workflow corrections |
| `change_orders.templates.manage` | Manage Templates  | Create and manage change order templates     | Grant to process owners who define CO standards           |

### Collaboration

| Permission          | UI Label          | Description                                     | Use Case                             |
| ------------------- | ----------------- | ----------------------------------------------- | ------------------------------------ |
| `comments.create`   | Add Comments      | Create comments on components and change orders | Grant to enable team discussions     |
| `comments.read`     | View Comments     | View comments and discussions                   | Required for collaborative workflows |
| `comments.update`   | Edit Comments     | Edit your own comments                          | Grant alongside comment creation     |
| `comments.delete`   | Delete Comments   | Delete your own comments                        | Grant alongside comment creation     |
| `comments.moderate` | Moderate Comments | Delete any comment (moderation)                 | Restrict to administrators           |

### Administration

#### Organization Settings

| Permission                       | UI Label            | Description                               | Use Case                                             |
| -------------------------------- | ------------------- | ----------------------------------------- | ---------------------------------------------------- |
| `organization.read`              | View Org Info       | View organization information             | Granted to all members by default                    |
| `organization.settings.update`   | Edit Org Settings   | Update organization settings              | Restrict to administrators                           |
| `organization.users.read`        | View Members        | View organization members                 | Granted to most roles for collaboration              |
| `organization.users.invite`      | Invite Members      | Invite new users to the organization      | Grant to team leads and HR personnel                 |
| `organization.users.remove`      | Remove Members      | Remove users from the organization        | Restrict to administrators                           |
| `organization.users.update_role` | Assign Member Roles | Change user roles within the organization | Restrict to administrators                           |
| `organization.libraries.create`  | Create Libraries    | Create new libraries in the organization  | Grant to administrators and project leads            |
| `organization.libraries.delete`  | Delete Libraries    | Delete libraries from the organization    | Restrict to administrators; deletion is irreversible |
| `organization.saml.configure`    | Configure SSO       | Configure SAML SSO settings               | Restrict to IT administrators                        |
| `organization.api_keys.manage`   | Manage API Keys     | Create and manage API keys                | Grant to developers and integrators                  |

#### Library Settings

| Permission                  | UI Label              | Description                              | Use Case                                          |
| --------------------------- | --------------------- | ---------------------------------------- | ------------------------------------------------- |
| `library.read`              | View Library Info     | View library information                 | Granted to all library members                    |
| `library.settings.update`   | Edit Library Settings | Update library settings                  | Restrict to library administrators                |
| `library.categories.manage` | Manage Categories     | Manage library categories and attributes | Grant to library administrators and data stewards |
| `library.statuses.manage`   | Manage Statuses       | Manage custom component statuses         | Grant to process owners                           |
| `library.features.manage`   | Toggle Features       | Enable/disable experimental features     | Restrict to administrators                        |

#### Role Management

| Permission     | UI Label     | Description                                | Use Case                             |
| -------------- | ------------ | ------------------------------------------ | ------------------------------------ |
| `roles.read`   | View Roles   | View available roles and their permissions | Granted to administrators            |
| `roles.create` | Create Roles | Create custom roles                        | Grant to organization administrators |
| `roles.update` | Edit Roles   | Modify custom role permissions             | Grant to organization administrators |
| `roles.delete` | Delete Roles | Delete custom roles                        | Grant to organization administrators |
| `roles.assign` | Assign Roles | Assign roles to users                      | Grant to user managers               |

## Permission Implications

Some permissions automatically satisfy requirements for related permissions. This follows the principle that higher-level access implies lower-level access.

### How It Works

If a user has a "higher" permission, they automatically satisfy checks for "lower" permissions:

```
delete → update → create → read
```

For example, a user with `components.update` can also perform `components.read` operations, even if `components.read` wasn't explicitly granted.

### Implication Rules

| If user has...                 | They can also...                                                     |
| ------------------------------ | -------------------------------------------------------------------- |
| `components.delete`            | `components.update`, `components.create`, `components.read`          |
| `components.update`            | `components.create`, `components.read`                               |
| `components.create`            | `components.read`                                                    |
| `labels.delete`                | `labels.update`, `labels.create`, `labels.read`                      |
| `labels.update`                | `labels.create`, `labels.read`                                       |
| `labels.create`                | `labels.read`                                                        |
| `change_orders.delete`         | `change_orders.update`, `change_orders.create`, `change_orders.read` |
| Any CO workflow permission     | `change_orders.read`                                                 |
| `comments.moderate`            | All other comment permissions                                        |
| Any role management permission | `roles.read`                                                         |
| Any user management permission | `organization.users.read`                                            |

{% hint style="info" %}
This means you don't need to explicitly grant read permissions if you're granting create, update, or delete permissions on the same resource.
{% endhint %}

## API Reference

### Required Headers

All authenticated GraphQL requests must include context headers:

```
x-organization: @org-slug    # Or organization UUID
x-library: @org-slug/lib     # Or library UUID (for library-scoped operations)
```

{% hint style="info" %}
**Permission enforcement is server-side; there is no public permission-check query.** Duro evaluates the caller's effective role and enforces the required permission automatically on every request to the public API (`https://api.durohub.com/graphql`). There is no client-callable query for testing a permission ahead of time — the internal `checkPermission` operation is **not** part of the public federated router schema and is internal-only to Duro's Foundation service for service-to-service calls. To reason about access from your integration, inspect a user's assigned roles and permissions (see [Querying Roles](#querying-roles)) or handle the `FORBIDDEN` error returned when a check fails.
{% endhint %}

### Querying Roles

#### Get All Permissions

Retrieve all available permissions in the system:

```graphql
query GetAllPermissions {
  roles {
    allPermissions {
      id
      name
      description
      resource
      action
    }
  }
}
```

#### Get Role by ID

Retrieve a specific role with its permissions:

```graphql
query GetRole($id: String!) {
  roles {
    findById(id: $id) {
      id
      name
      description
      type
      isAdmin
      isSystemRole
      permissions {
        id
        name
        description
      }
    }
  }
}
```

#### Get Organization Members with Roles

Query organization members to see their assigned roles and library access:

```graphql
query GetOrganizationMembers($slug: String!) {
  organization {
    find(filter: { slug: $slug }) {
      members {
        id
        userId
        user {
          id
          primaryEmail
          name
        }
        rbacRole {
          id
          name
          type
          isAdmin
          permissions {
            name
          }
        }
        hasDirectOrgRole
        libraryAccess {
          libraryId
          libraryName
          role {
            id
            name
          }
          isOverride
        }
      }
    }
  }
}
```

{% hint style="info" %}
The `members` field returns all organization members. To find a specific user, filter the results by `userId` in your application code.
{% endhint %}

### Managing Custom Roles

#### Create a Custom Role

```graphql
mutation CreateRole($input: CreateRoleInput!) {
  roles {
    create(input: $input) {
      id
      name
      description
      type
      isAdmin
      isSystemRole
      permissions {
        id
        name
      }
    }
  }
}
```

**Variables:**

```json
{
  "input": {
    "name": "Quality Engineer",
    "description": "QA team member who reviews and approves changes",
    "type": "ORG",
    "extId": "your-organization-id",
    "permissionIds": [
      "permission-uuid-1",
      "permission-uuid-2"
    ],
    "isAdmin": false
  }
}
```

{% hint style="warning" %}
Creating roles requires the `roles.create` permission.
{% endhint %}

#### Update a Custom Role

```graphql
mutation UpdateRole($input: UpdateRoleInput!) {
  roles {
    update(input: $input) {
      id
      name
      description
      permissions {
        id
        name
      }
    }
  }
}
```

**Variables:**

```json
{
  "input": {
    "id": "role-uuid",
    "organizationId": "org-uuid",
    "name": "Senior Quality Engineer",
    "description": "Updated description",
    "permissionIds": ["updated-permission-list"]
  }
}
```

#### Delete a Custom Role

When deleting a role with assigned users, you must specify a migration target:

```graphql
mutation DeleteRole($input: DeleteRoleInput!) {
  roles {
    delete(input: $input)
  }
}
```

**Variables:**

```json
{
  "input": {
    "id": "role-to-delete-uuid",
    "organizationId": "org-uuid",
    "migrateToRoleId": "target-role-uuid"
  }
}
```

#### Count Users in a Role

Before deleting a role, check how many users are assigned:

```graphql
query CountUsersInRole($roleId: String!) {
  roles {
    usersInRole(roleId: $roleId)
  }
}
```

## Custom Role Best Practices

### When to Create Custom Roles

Create custom roles when system roles don't fit your workflow:

* **QA Engineer**: Read access + change order approval, but no component editing
* **External Auditor**: Read access + export capabilities for compliance reviews
* **Junior Engineer**: Create components but not delete; submit COs but not approve
* **Supply Chain Manager**: Supplier management + procurement-specific permissions

### Custom Role Guidelines

1. **Start from a template** - Use the quick-start templates in the UI to begin with a baseline permission set

![Create Custom Role with quick-start templates](/files/zabNiVJzwSGRTjtfFygd)

2. **Follow least privilege** - Grant only the permissions needed for the role's responsibilities
3. **Document the purpose** - Use the description field to explain who should be assigned this role
4. **Review periodically** - Audit custom roles quarterly to ensure they still align with team needs

### Naming Conventions

* Use job titles or functional descriptions: "Quality Engineer", "External Reviewer"
* Avoid generic names like "Custom Role 1"
* Include scope if relevant: "Library Admin - Prototypes"

## User Access Patterns

![Users & Access list showing different access patterns](/files/JcPSlry5s3MREGIXlMdW)

### Organization-Wide Access

For internal team members who need consistent access across all libraries:

1. Assign an **organization role** (Admin, Editor, Reviewer, Viewer)
2. User automatically gets that role in all libraries, including future ones
3. Optionally add **library overrides** for exceptions

**Example**: An engineer with Editor org role who needs Admin access to their project:

* Org Role: Editor (applies everywhere)
* Library Override: Admin on "Project Phoenix" library

![Invite Team Members with organization role and library access options](/files/fhBVZ7dRfclqFI8lIH6T)

### Per-Library Access

For external partners, contractors, or users with limited scope:

1. **Do not** assign an organization role
2. Add specific **library assignments** with appropriate roles
3. User only has access to explicitly assigned libraries

**Example**: An external supplier:

* No org role
* Supplier role on "Shared Components" library
* Viewer role on "Product Specs" library

## Troubleshooting

### Common Issues

**User can't access a library**

* Check if they have an org role OR a library-specific role
* Verify the library role hasn't been accidentally removed

**User has more access than expected**

* Check for admin bypass (`isAdmin: true` on their role)
* Review permission implications - they may have a higher permission that satisfies the check

**Custom role changes not taking effect**

* Changes to org roles cascade to library roles automatically
* Users may need to refresh their session

### Next Steps

Learn about setting up [Webhooks](/advanced-topics/webhooks) to get notified of role and access changes in real-time.


# Webhooks

Stay informed about important events in your Duro library with real-time webhook notifications.

{% hint style="info" %}
Webhooks follow a **"Ping then Pull"** pattern - you receive lightweight event notifications with key metadata, then fetch full resource details using our GraphQL API when needed.
{% endhint %}

## Overview

Duro webhooks let your applications receive real-time notifications when important events occur in your library. Instead of constantly polling for changes, webhooks push event notifications directly to your specified endpoints, helping you build responsive integrations that react immediately to data changes.

### Key Benefits

* **Real-time updates** - Get notified instantly when data changes
* **Efficient integration** - No need for constant API polling
* **Selective subscriptions** - Subscribe only to the events you care about
* **Reliable delivery** - Built-in retry mechanisms with exponential backoff
* **Secure** - HMAC-SHA256 signature verification to ensure authenticity

## How Webhooks Work

1. **Event occurs** - Something happens in Duro (e.g., a component is updated)
2. **Notification sent** - Duro sends a lightweight JSON payload to your webhook URL
3. **Fetch full data** - Your application uses the provided metadata to fetch complete details via GraphQL as needed

This pattern keeps webhook payloads small and fast while giving you access to all the data you need.

***

## Available Events

Webhooks are scoped to a specific **library**. Each webhook can subscribe to one or more event types.

### Component Events

These events fire when components in your library are created, updated, or deleted:

| GraphQL Enum        | Payload Value        | Description                                |
| ------------------- | -------------------- | ------------------------------------------ |
| `COMPONENT_CREATED` | `components.created` | A new component was created in the library |
| `COMPONENT_UPDATED` | `components.updated` | An existing component was modified         |
| `COMPONENT_DELETED` | `components.deleted` | A component was removed from the library   |

{% hint style="info" %}
When subscribing to events via GraphQL, use the **enum name** (e.g., `COMPONENT_CREATED`). When processing webhook payloads, the `event` field contains the **string value** (e.g., `components.created`).
{% endhint %}

### Change Order Events

Track the full lifecycle of change orders in your library with these events:

| GraphQL Enum                           | Payload Value                           | Description                                                                 |
| -------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------- |
| `CHANGE_ORDER_OPENED`                  | `change_orders.opened`                  | A change order transitioned from draft to open status                       |
| `CHANGE_ORDER_UPDATED`                 | `change_orders.updated`                 | Change order details were modified (name, description, etc.)                |
| `CHANGE_ORDER_DELETED`                 | `change_orders.deleted`                 | A change order was archived                                                 |
| `CHANGE_ORDER_STAGE_TRANSITION`        | `change_orders.stage_transition`        | Change order moved to a different workflow stage                            |
| `CHANGE_ORDER_STAGE_REVIEWER_DECISION` | `change_orders.stage_reviewer_decision` | A reviewer approved or rejected their stage                                 |
| `CHANGE_ORDER_RESOLUTION`              | `change_orders.resolution`              | Change order was fully approved, rejected, or withdrawn                     |
| `CHANGE_ORDER_CLOSED`                  | `change_orders.closed`                  | A change order transitioned into the terminal closed state after resolution |

{% hint style="info" %}
**Semantic Event Priority**: When a change triggers multiple events (e.g., updating status from draft to open), Duro sends the more specific semantic event (`CHANGE_ORDER_OPENED`) rather than the generic `CHANGE_ORDER_UPDATED`. This prevents duplicate notifications and makes event handling more predictable.
{% endhint %}

***

## Setting Up Webhooks

### Create a Webhook

To create a webhook, you'll need:

* The `x-library` header set to specify which library to monitor
* A `url` - your HTTPS endpoint that will receive notifications
* A list of `events` - which event types to subscribe to

{% hint style="info" %}
**Required Headers**: All webhook operations require the `x-api-key`, `x-organization`, and `x-library` headers. The webhook will be created for the library specified in the `x-library` header.
{% endhint %}

```graphql
mutation CreateWebhook {
  webhooks {
    create(input: {
      name: "ERP Sync Webhook"
      description: "Syncs component updates to our ERP system"
      url: "https://api.yourcompany.com/webhooks/duro"
      events: [COMPONENT_CREATED, COMPONENT_UPDATED]
      signingSecret: "your-secret-key-min-16-chars"
      timeoutSeconds: 30
      maxRetries: 3
      isEnabled: true
    }) {
      id
      name
      url
      events
      isEnabled
      createdAt
    }
  }
}
```

#### Configuration Options

| Field            | Required  | Description                                                            | Default |
| ---------------- | --------- | ---------------------------------------------------------------------- | ------- |
| `name`           | Yes       | Unique name for this webhook (1-100 characters)                        | -       |
| `url`            | Yes       | HTTPS endpoint URL (must be valid HTTPS)                               | -       |
| `events`         | Yes       | Array of event types to subscribe to                                   | -       |
| `description`    | No        | Optional description (max 500 characters)                              | `null`  |
| `signingSecret`  | No        | Secret for HMAC signature verification (min 16 characters if provided) | `null`  |
| `timeoutSeconds` | No        | Request timeout in seconds (5-300)                                     | `30`    |
| `maxRetries`     | No        | Maximum retry attempts on failure (0-10)                               | `3`     |
| `isEnabled`      | No        | Whether the webhook is active                                          | `true`  |
| `isArchived`     | Read-only | Whether the webhook has been archived (set via `archive` mutation)     | `false` |

### List Your Webhooks

Retrieve all webhooks for the library specified in your `x-library` header:

```graphql
query GetMyWebhooks {
  webhooks {
    findAll {
      id
      name
      description
      url
      events
      isEnabled
      isArchived
      timeoutSeconds
      maxRetries
      createdAt
      updatedAt
    }
  }
}
```

{% hint style="info" %}
The `findAll` query returns webhooks for the library specified in the `x-library` header. Only active (non-archived) webhooks are returned.
{% endhint %}

### Get a Specific Webhook

```graphql
query GetWebhook {
  webhooks {
    findOne(id: "webhook-uuid") {
      id
      name
      description
      url
      events
      isEnabled
      isArchived
      signingSecret
      timeoutSeconds
      maxRetries
      createdAt
    }
  }
}
```

{% hint style="info" %}
The `findOne` query returns a `webhook_not_found` error if the webhook has been archived.
{% endhint %}

### Update a Webhook

You can update any webhook configuration field:

```graphql
mutation UpdateWebhook {
  webhooks {
    update(
      id: "webhook-uuid"
      input: {
        name: "Updated Webhook Name"
        events: [COMPONENT_CREATED, COMPONENT_UPDATED, COMPONENT_DELETED]
        isEnabled: false
        maxRetries: 5
      }
    ) {
      id
      name
      events
      isEnabled
      maxRetries
    }
  }
}
```

### Add Events to a Webhook

Add additional event subscriptions without removing existing ones:

```graphql
mutation AddWebhookEvents {
  webhooks {
    addEvents(
      id: "webhook-uuid"
      input: {
        events: [COMPONENT_DELETED]
      }
    ) {
      id
      events
    }
  }
}
```

### Remove Events from a Webhook

Remove specific event subscriptions:

```graphql
mutation RemoveWebhookEvents {
  webhooks {
    removeEvents(
      id: "webhook-uuid"
      input: {
        events: [COMPONENT_DELETED]
      }
    ) {
      id
      events
    }
  }
}
```

### Archive a Webhook

When you no longer need a webhook but want to preserve its configuration history, you can archive it instead of deleting it. Archived webhooks:

* **Stop receiving events** - No new event deliveries will be attempted
* **Are hidden from queries** - Won't appear in `findAll` or `findOne` results
* **Free up the name** - You can create a new webhook with the same name
* **Cannot be unarchived** - This action is permanent

```graphql
mutation ArchiveWebhook {
  webhooks {
    archive(id: "webhook-uuid") {
      id
      name
      isArchived
    }
  }
}
```

{% hint style="warning" %}
Archiving a webhook is permanent and cannot be undone. If you need to temporarily stop webhook deliveries, consider using the `update` mutation to set `isEnabled: false` instead.
{% endhint %}

#### When to Archive vs. Disable

| Action                           | Use Case                                                                      |
| -------------------------------- | ----------------------------------------------------------------------------- |
| **Disable** (`isEnabled: false`) | Temporarily pause deliveries; webhook remains queryable and can be re-enabled |
| **Archive**                      | Permanently retire a webhook; frees up the name for reuse                     |

***

## Webhook Payloads

All webhook notifications follow a consistent JSON structure:

```json
{
  "event": "components.updated",
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "sourceId": "nats-msg-12345",
  "timestamp": "2025-01-15T10:30:00.000Z",
  "metadata": {
    "componentId": "comp-abc123-uuid",
    "revisionValue": "1.A",
    "version": 1
  }
}
```

### Payload Fields Explained

| Field       | Type   | Description                                        |
| ----------- | ------ | -------------------------------------------------- |
| `event`     | string | The event type (e.g., `components.created`)        |
| `eventId`   | string | Unique identifier for this webhook delivery (UUID) |
| `sourceId`  | string | Internal event ID for tracking and debugging       |
| `timestamp` | string | ISO 8601 timestamp when the event occurred         |
| `metadata`  | object | Event-specific data (see below)                    |

### Component Event Metadata

For component events, the `metadata` object contains:

| Field           | Type   | Description                                 |
| --------------- | ------ | ------------------------------------------- |
| `componentId`   | string | UUID of the affected component              |
| `revisionValue` | string | Current revision value (e.g., "1.A", "2.B") |
| `version`       | number | Current version number                      |

#### Example: Component Created

```json
{
  "event": "components.created",
  "eventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "sourceId": "nats-msg-98765",
  "timestamp": "2025-01-15T14:22:33.456Z",
  "metadata": {
    "componentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "revisionValue": "1.A",
    "version": 1
  }
}
```

#### Example: Component Updated

```json
{
  "event": "components.updated",
  "eventId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
  "sourceId": "nats-msg-54321",
  "timestamp": "2025-01-15T15:45:12.789Z",
  "metadata": {
    "componentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "revisionValue": "1.B",
    "version": 2
  }
}
```

### Change Order Event Metadata

Each change order event type includes different metadata fields based on the event context.

#### `change_orders.opened`

Fired when a change order transitions from `draft` to `open` status (submitted for review).

| Field           | Type   | Description               |
| --------------- | ------ | ------------------------- |
| `changeOrderId` | string | UUID of the change order  |
| `status`        | string | New status value (`open`) |

```json
{
  "event": "change_orders.opened",
  "eventId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
  "sourceId": "nats-msg-11111",
  "timestamp": "2025-01-15T09:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": "open"
  }
}
```

#### `change_orders.updated`

Fired when change order details are modified (name, description, etc.) without triggering a semantic event like `opened` or `resolution`.

| Field           | Type   | Description              |
| --------------- | ------ | ------------------------ |
| `changeOrderId` | string | UUID of the change order |

```json
{
  "event": "change_orders.updated",
  "eventId": "d4e5f6a7-b8c9-0123-def0-456789012345",
  "sourceId": "nats-msg-22222",
  "timestamp": "2025-01-15T10:30:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

#### `change_orders.deleted`

Fired when a change order is archived.

| Field           | Type   | Description              |
| --------------- | ------ | ------------------------ |
| `changeOrderId` | string | UUID of the change order |

```json
{
  "event": "change_orders.deleted",
  "eventId": "e5f6a7b8-c9d0-1234-ef01-567890123456",
  "sourceId": "nats-msg-33333",
  "timestamp": "2025-01-15T11:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

#### `change_orders.stage_transition`

Fired when a change order moves between workflow stages.

| Field              | Type   | Description                                                          |
| ------------------ | ------ | -------------------------------------------------------------------- |
| `changeOrderId`    | string | UUID of the change order                                             |
| `previousStageId`  | string | UUID of the previous stage (omitted if starting workflow)            |
| `newStageId`       | string | UUID of the new stage (omitted if resolved/completed)                |
| `transitionReason` | string | Reason for the transition (e.g., `stage_approved`, `stage_rejected`) |

```json
{
  "event": "change_orders.stage_transition",
  "eventId": "f6a7b8c9-d0e1-2345-f012-678901234567",
  "sourceId": "nats-msg-44444",
  "timestamp": "2025-01-15T12:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "previousStageId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
    "newStageId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
    "transitionReason": "stage_approved"
  }
}
```

#### `change_orders.stage_reviewer_decision`

Fired when a reviewer makes a decision (approve/reject) on their assigned stage.

| Field           | Type   | Description                                  |
| --------------- | ------ | -------------------------------------------- |
| `changeOrderId` | string | UUID of the change order                     |
| `stageId`       | string | UUID of the stage being reviewed             |
| `reviewerId`    | string | UUID of the reviewer who made the decision   |
| `decision`      | string | The decision made (`approved` or `rejected`) |

```json
{
  "event": "change_orders.stage_reviewer_decision",
  "eventId": "a7b8c9d0-e1f2-3456-0123-789012345678",
  "sourceId": "nats-msg-55555",
  "timestamp": "2025-01-15T13:30:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "stageId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
    "reviewerId": "d4e5f6a7-b8c9-0123-def0-456789012345",
    "decision": "approved"
  }
}
```

#### `change_orders.resolution`

Fired when a change order reaches a final resolution (approved, rejected, or withdrawn).

| Field           | Type   | Description                                               |
| --------------- | ------ | --------------------------------------------------------- |
| `changeOrderId` | string | UUID of the change order                                  |
| `resolution`    | string | Final resolution (`approved`, `rejected`, or `withdrawn`) |

```json
{
  "event": "change_orders.resolution",
  "eventId": "b8c9d0e1-f2a3-4567-1234-890123456789",
  "sourceId": "nats-msg-66666",
  "timestamp": "2025-01-15T14:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "resolution": "approved"
  }
}
```

#### `change_orders.closed`

Fired when a change order transitions into the terminal `closed` state after it has been resolved. This is distinct from `change_orders.resolution`: resolution records the decision (approved/rejected/withdrawn), while closed marks the change order moving into its final closed status. Both events fire for a given close.

| Field            | Type   | Description                                                                 |
| ---------------- | ------ | --------------------------------------------------------------------------- |
| `changeOrderId`  | string | UUID of the change order                                                    |
| `sequentialId`   | number | Human-facing sequential number of the change order (e.g. `2` for CO-2)      |
| `libraryId`      | string | UUID of the library the change order belongs to                             |
| `status`         | string | Always `closed` for this event                                              |
| `resolution`     | string | Resolution that preceded the close (`approved`, `rejected`, or `withdrawn`) |
| `resolvedAt`     | string | ISO 8601 timestamp of when the change order was resolved                    |
| `closedAt`       | string | ISO 8601 timestamp of when the change order was closed                      |
| `closedByUserId` | string | UUID of the user who closed the change order                                |

```json
{
  "event": "change_orders.closed",
  "eventId": "c9d0e1f2-a3b4-5678-2345-901234567890",
  "sourceId": "nats-msg-77777",
  "timestamp": "2025-01-15T14:05:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "sequentialId": 2,
    "libraryId": "f1e2d3c4-b5a6-7890-abcd-ef0987654321",
    "status": "closed",
    "resolution": "approved",
    "resolvedAt": "2025-01-15T14:05:00.000Z",
    "closedAt": "2025-01-15T14:05:00.000Z",
    "closedByUserId": "9876fedc-ba98-7654-3210-fedcba987654"
  }
}
```

### HTTP Headers

Each webhook request includes these headers:

| Header                | Value                                                                                                                             |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                                                                                |
| `User-Agent`          | `Duro-Webhook-Service/1.0`                                                                                                        |
| `X-Webhook-Signature` | Versioned HMAC-SHA256 signature in the form `sha256=<hex digest>` (only sent when a `signingSecret` is configured on the webhook) |

***

## Signing Secrets

Every Duro webhook can carry a **signing secret** that lets your endpoint cryptographically verify each delivery actually came from Duro and was not modified in transit. Treat the signing secret like any other credential — it is the only thing protecting your endpoint from spoofed requests.

### How Signing Works

When a webhook has a `signingSecret`, Duro signs every outbound delivery as follows:

1. Compute `HMAC_SHA256(rawRequestBody, signingSecret)` and hex-encode the digest.
2. Send the result in the `X-Webhook-Signature` header, prefixed with the algorithm version.

The header value uses a versioned, Stripe-style format:

```
X-Webhook-Signature: sha256=<hex digest>
```

The `sha256=` prefix is reserved for future algorithm upgrades — your verification code should split on `=` and reject any prefix it does not understand rather than assume a bare hex digest.

{% hint style="info" %}
The signature is computed over the **exact bytes of the request body** that Duro sent. Re-serializing the JSON (for example by parsing and re-stringifying it) will produce a different signature and verification will fail. Always verify against the raw body.
{% endhint %}

### Providing a Signing Secret

The `signingSecret` field is **optional** on `webhooks.create` and `webhooks.update`:

* If you provide a `signingSecret`, Duro stores the value you provided (minimum 16 characters).
* If you omit `signingSecret` on create, Duro **generates a cryptographically secure signing secret for you by default**. You can read it back via the `findOne` query.
* If a webhook has no signing secret configured at all, deliveries are sent unsigned and the `X-Webhook-Signature` header is omitted.

Because Duro auto-generates a signing secret on create, in practice every new webhook is signed unless you have deliberately cleared the secret.

### Verifying Signatures

Your endpoint should:

1. Read the **raw request body bytes** before any JSON parsing.
2. Read the `X-Webhook-Signature` header and split it into algorithm + digest on the first `=`.
3. Compute `HMAC_SHA256(rawBody, signingSecret)` and hex-encode it.
4. Compare the computed digest to the digest from the header using a **timing-safe comparison**.
5. Reject the request if the header is missing, the algorithm is unknown, or the digests do not match.

#### Verifying Signatures (Node.js)

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(rawBody, headerValue, secret) {
  if (!headerValue) return false;

  // Header format is "sha256=<hex digest>". Reject anything else.
  const [algorithm, providedHex] = headerValue.split('=', 2);
  if (algorithm !== 'sha256' || !providedHex) return false;

  const expectedHex = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const providedBuf = Buffer.from(providedHex, 'hex');
  const expectedBuf = Buffer.from(expectedHex, 'hex');

  // timingSafeEqual throws if the buffer lengths differ, so guard first.
  if (providedBuf.length !== expectedBuf.length) return false;

  return crypto.timingSafeEqual(providedBuf, expectedBuf);
}

// Express.js middleware example. The `express.raw` parser is critical here —
// signature verification must run against the unparsed request body.
app.post('/webhooks/duro', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const rawBody = req.body; // Buffer

  if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Signature verified - safe to parse and process.
  const payload = JSON.parse(rawBody.toString('utf8'));
  // ... handle the event

  res.status(200).send('OK');
});
```

#### Verifying Signatures (Python)

```python
import hmac
import hashlib

def verify_webhook_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
    if not header_value or "=" not in header_value:
        return False

    algorithm, _, provided_hex = header_value.partition("=")
    if algorithm != "sha256" or not provided_hex:
        return False

    expected_hex = hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()

    # compare_digest handles length mismatches safely.
    return hmac.compare_digest(provided_hex, expected_hex)

# Flask example
import os
from flask import Flask, request, abort

app = Flask(__name__)

@app.route('/webhooks/duro', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')

    if not verify_webhook_signature(
        request.get_data(),  # raw bytes, not request.get_json()
        signature,
        os.environ['WEBHOOK_SECRET'],
    ):
        abort(401)

    payload = request.get_json()
    # ... handle the event

    return 'OK', 200
```

### Endpoint Hardening Checklist

* **Reject unsigned requests when you expect signatures.** If your webhook has a signing secret configured, treat a missing `X-Webhook-Signature` header as an authentication failure (`401`).
* **Verify against the raw body.** Parsing JSON before verifying will change the byte sequence and break the signature.
* **Use a constant-time comparison.** Use `crypto.timingSafeEqual` (Node) or `hmac.compare_digest` (Python) — never `===` / `==`.
* **Pin the algorithm.** Refuse any header that does not start with `sha256=` so a future algorithm migration is explicit.
* **Dedupe with `eventId`.** Signature verification proves authenticity, not uniqueness — combine it with idempotent processing keyed on `eventId`.

### Security Best Practices

* **Always use HTTPS** - Webhook URLs must use HTTPS (enforced by Duro)
* **Verify signatures** - Always validate the `X-Webhook-Signature` header
* **Use timing-safe comparison** - Prevent timing attacks when comparing signatures
* **Keep secrets secure** - Store your `signingSecret` in environment variables, never in code
* **Rotate secrets periodically** - Update your signing secret via `webhooks.update` regularly

***

## Fetching Full Resource Data

After receiving a webhook notification, use the provided IDs to fetch complete resource details via GraphQL.

### Fetch Component Details

```graphql
query GetComponentDetails($componentId: ID!) {
  components {
    get(filter: { ids: [$componentId] }) {
      connection {
        edges {
          node {
            id
            cpn
            name
            description
            revision
            status
            category {
              name
              code
            }
            sources {
              manufacturer
              mpn
            }
            specs {
              name
              value
              unit
            }
          }
        }
      }
    }
  }
}
```

### Fetch Change Order Details

```graphql
query GetChangeOrderDetails($changeOrderId: ID!) {
  changeOrders {
    get(id: $changeOrderId) {
      id
      name
      description
      status
      resolution
      createdAt
      updatedAt
      stage {
        id
        name
        order
      }
      items {
        id
        component {
          id
          cpn
          name
        }
        action
      }
      reviewers {
        id
        user {
          id
          email
          name
        }
        decision
        decidedAt
      }
    }
  }
}
```

### Example: Complete Webhook Handler

Here's a complete example showing how to receive a webhook and fetch the full component data:

```javascript
const express = require('express');
const crypto = require('crypto');
const { GraphQLClient } = require('graphql-request');

const app = express();
const graphqlClient = new GraphQLClient('https://api.durohub.com/graphql', {
  headers: {
    'x-api-key': process.env.DURO_API_KEY,
  },
});

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

function verifySignature(rawBody, headerValue) {
  if (!headerValue) return false;

  const [algorithm, providedHex] = headerValue.split('=', 2);
  if (algorithm !== 'sha256' || !providedHex) return false;

  const expectedHex = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  const provided = Buffer.from(providedHex, 'hex');
  const expected = Buffer.from(expectedHex, 'hex');
  if (provided.length !== expected.length) return false;

  return crypto.timingSafeEqual(provided, expected);
}

// Use raw body for signature verification
app.post('/webhooks/duro', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const rawBody = req.body; // Buffer

  // Verify signature against the raw bytes Duro signed.
  if (!verifySignature(rawBody, signature)) {
    console.error('Invalid webhook signature');
    return res.status(401).send('Unauthorized');
  }

  // Acknowledge receipt immediately (respond within timeout)
  res.status(200).send('OK');

  // Process asynchronously
  const payload = JSON.parse(rawBody.toString('utf8'));
  await processWebhook(payload);
});

async function processWebhook(payload) {
  const { event, eventId, metadata } = payload;

  console.log(`Processing ${event} event (${eventId})`);

  switch (event) {
    // Component events
    case 'components.created':
    case 'components.updated':
      await handleComponentChange(metadata);
      break;
    case 'components.deleted':
      await handleComponentDeleted(metadata);
      break;

    // Change order events
    case 'change_orders.opened':
      await handleChangeOrderOpened(metadata);
      break;
    case 'change_orders.updated':
      await handleChangeOrderUpdated(metadata);
      break;
    case 'change_orders.deleted':
      await handleChangeOrderDeleted(metadata);
      break;
    case 'change_orders.stage_transition':
      await handleStageTransition(metadata);
      break;
    case 'change_orders.stage_reviewer_decision':
      await handleReviewerDecision(metadata);
      break;
    case 'change_orders.resolution':
      await handleChangeOrderResolution(metadata);
      break;
    case 'change_orders.closed':
      await handleChangeOrderClosed(metadata);
      break;

    default:
      console.log(`Unhandled event type: ${event}`);
  }
}

async function handleComponentChange(metadata) {
  const { componentId, revisionValue, version } = metadata;

  // Fetch full component details from Duro
  const query = `
    query GetComponent($id: ID!) {
      components {
        get(filter: { ids: [$id] }) {
          connection {
            edges {
              node {
                id
                cpn
                name
                description
                revision
                status
              }
            }
          }
        }
      }
    }
  `;

  const data = await graphqlClient.request(query, { id: componentId });
  const component = data.components.get.connection.edges[0]?.node;

  if (component) {
    console.log(`Component ${component.cpn} (${component.name}) was updated`);
    // Sync to your ERP, database, or other system
    await syncToExternalSystem(component);
  }
}

async function handleComponentDeleted(metadata) {
  const { componentId } = metadata;
  console.log(`Component ${componentId} was deleted`);
  // Handle deletion in your external systems
  await removeFromExternalSystem(componentId);
}

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});
```

***

## Retry Logic & Error Handling

Duro automatically retries failed webhook deliveries using exponential backoff.

### Retry Schedule

A delivery is retried up to `maxRetries` times (the webhook's configured value, `0`–`10`, default `3`) **after** the initial attempt. Each attempt is given `timeoutSeconds + 10` to respond before it counts as a timeout.

Delays grow exponentially (2× multiplier) starting at 60 seconds, capped at 5 minutes:

| Retry          | Delay before retry        |
| -------------- | ------------------------- |
| 1st retry      | 60 seconds                |
| 2nd retry      | 120 seconds (2 min)       |
| 3rd retry      | 240 seconds (4 min)       |
| 4th retry      | 300 seconds (5 min — cap) |
| 5th–10th retry | 300 seconds (5 min — cap) |

With the default `maxRetries: 3`, a failing delivery is attempted 4 times total (1 initial + 3 retries) over roughly 7 minutes of backoff before the log is marked `FAILED`.

{% hint style="info" %}
**Not every failure is retried.** If the webhook is disabled or archived (or its log row no longer exists), the delivery fails immediately and does not consume retries.
{% endhint %}

### What Counts as Success?

* **Success**: HTTP status codes 200-299
* **Failure**: All other status codes, timeouts, or connection errors

### Your Endpoint Should

1. **Respond quickly** - Return a 200 status code immediately, then process asynchronously
2. **Handle duplicates** - Use `eventId` for idempotency; you may receive the same event more than once
3. **Log failures** - Track and investigate webhook processing failures
4. **Be available** - Ensure your endpoint is highly available to receive webhooks

### Example: Idempotent Processing

```javascript
const processedEvents = new Set(); // In production, use Redis or a database

async function processWebhook(payload) {
  const { eventId, event, metadata } = payload;

  // Check if we've already processed this event
  if (processedEvents.has(eventId)) {
    console.log(`Event ${eventId} already processed, skipping`);
    return;
  }

  // Process the event
  await handleEvent(event, metadata);

  // Mark as processed
  processedEvents.add(eventId);
}
```

***

## Monitoring Webhook Activity

Every delivery attempt is recorded as a **webhook log**. Use the `filterLogs` query to monitor delivery health, build dashboards, audit activity over a time window, or drive reprocessing workflows.

{% hint style="warning" %}
`filterLogs` replaces the deprecated `getLogs` query. `getLogs` returned an unpaginated array for a single webhook; `filterLogs` adds cursor pagination, multi-condition filtering (`and` / `or`), and the ability to query across every webhook in your library at once. **`getLogs` will be removed in a future release — migrate to `filterLogs`.**
{% endhint %}

### The `filterLogs` query

`filterLogs` returns a Relay-style **connection**: a page of `edges` (each wrapping a `WebhookLog` node and its pagination `cursor`), a `pageInfo` block, and a `totalCount` of all logs matching your filter.

```graphql
query RecentDeliveries {
  webhooks {
    filterLogs(input: {
      and: [{ webhookId: { eq: "webhook-uuid" } }]
      pagination: { first: 25, orderBy: { field: "createdAt", direction: DESC } }
    }) {
      totalCount
      pageInfo {
        hasNextPage
        endCursor
        resultsReturnedCount
      }
      edges {
        cursor
        node {
          id
          event
          status
          isAcknowledged
          responseCode
          responseTimeMs
          deliveryErrorMessage
          attemptCount
          processedState
          createdAt
        }
      }
    }
  }
}
```

### Filter input structure

The `input` is a `WebhookLogFilterInput`:

| Field        | Type                                | Description                                      |
| ------------ | ----------------------------------- | ------------------------------------------------ |
| `and`        | `[WebhookLogFilterConditionInput!]` | Conditions that must **all** match (logical AND) |
| `or`         | `[WebhookLogFilterConditionInput!]` | Conditions where **any** may match (logical OR)  |
| `pagination` | `PaginationInput`                   | Page size, cursor, and sort order                |

Each entry in `and` / `or` is a `WebhookLogFilterConditionInput`:

| Condition field  | Operator type                         | Filters on                                                                                                      |
| ---------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `webhookId`      | `UUIDFilterInput`                     | The webhook a log belongs to                                                                                    |
| `createdAt`      | `DateFilterInput`                     | When the log row was created (event received)                                                                   |
| `updatedAt`      | `DateFilterInput`                     | When the log row was last modified (last delivery attempt / processing update)                                  |
| `isAcknowledged` | `BooleanFilterInput`                  | Whether your endpoint returned a 2xx (`true` = delivered)                                                       |
| `processedState` | `WebhookLogProcessedStateFilterInput` | Your integration's processing outcome (see [`markProcessed`](#tracking-processing-outcomes-with-markprocessed)) |

{% hint style="info" %}
`status` and `event` are returned on the log node but are **not** filterable server-side. To surface undelivered logs, filter on `isAcknowledged: { eq: false }` (matches `PENDING` and `FAILED`); to narrow by event type, return `event` and filter client-side.
{% endhint %}

#### Filter operators

```graphql
# UUIDFilterInput — exact, set membership, or negation
{ eq: "uuid" }
{ in: ["uuid-a", "uuid-b"] }
{ neq: "uuid" }

# DateFilterInput — open-ended bounds, a single calendar day, or an inclusive range
{ after: "2026-05-01T00:00:00Z" }                 # strictly after (exclusive)
{ before: "2026-05-28T00:00:00Z" }                # strictly before (exclusive)
{ on: "2026-05-22T00:00:00-08:00" }               # the calendar day starting at this instant
{ rangeStart: "2026-05-01T00:00:00Z", rangeEnd: "2026-05-28T23:59:59Z" }  # inclusive range

# BooleanFilterInput
{ eq: true }

# WebhookLogProcessedStateFilterInput — SUCCESS | FAIL, plus null-awareness
{ eq: SUCCESS }
{ neq: FAIL }        # NOTE: also excludes rows where processedState IS NULL (see below)
{ isNull: true }     # only unprocessed logs
```

{% hint style="info" %}
`processedState` uses standard SQL three-valued logic: `neq: FAIL` excludes both `FAIL` rows **and** unprocessed (`null`) rows. To match "anything that isn't FAIL, including not-yet-processed," combine `neq: FAIL` with `isNull: true` in an `or`.
{% endhint %}

#### Pagination

`PaginationInput` is cursor-based and **forward-only**:

| Field     | Type                | Description                                                                                   |
| --------- | ------------------- | --------------------------------------------------------------------------------------------- |
| `first`   | `Int`               | Maximum number of logs to return in this page                                                 |
| `after`   | `String`            | Return logs after this cursor — pass the previous page's `pageInfo.endCursor`                 |
| `orderBy` | `GenericOrderInput` | `{ field: String!, direction: ASC \| DESC }` — e.g. `{ field: "createdAt", direction: DESC }` |

Walk through all pages by repeating the query with `after: <endCursor>` until `pageInfo.hasNextPage` is `false`. `hasPreviousPage` is always `false` in v1.

### The webhook log node

A `WebhookLog` carries three groups of fields:

**Delivery (managed by Duro — did we reach your endpoint?)**

| Field                  | Type             | Description                                                                   |
| ---------------------- | ---------------- | ----------------------------------------------------------------------------- |
| `status`               | `WebhookStatus!` | `PENDING`, `DELIVERED`, `FAILED` (`RETRYING` is legacy and no longer written) |
| `isAcknowledged`       | `Boolean!`       | `true` once your endpoint returns a 2xx                                       |
| `responseCode`         | `Float`          | HTTP status code returned by your endpoint                                    |
| `responseTimeMs`       | `Float`          | Round-trip time of the delivery request                                       |
| `attemptCount`         | `Float!`         | Number of delivery attempts made                                              |
| `deliveryErrorMessage` | `String`         | Last delivery error (timeout, connection error, non-2xx)                      |
| `nextRetryAt`          | `DateTime`       | When the next retry is scheduled, if retrying                                 |

**Processing (managed by you via** [**`markProcessed`**](#tracking-processing-outcomes-with-markprocessed)**)**

| Field                    | Type                       | Description                                       |
| ------------------------ | -------------------------- | ------------------------------------------------- |
| `processedState`         | `WebhookLogProcessedState` | `SUCCESS`, `FAIL`, or `null` (not yet processed)  |
| `processedAt`            | `DateTime`                 | When you reported the processing outcome          |
| `processingErrorMessage` | `String`                   | Failure reason you supplied when reporting `FAIL` |

**Identity & content**

| Field                     | Type          | Description                                  |
| ------------------------- | ------------- | -------------------------------------------- |
| `id`                      | `ID!`         | Log ID — pass this to `markProcessed`        |
| `webhookId`               | `ID!`         | The webhook that produced this log           |
| `event`                   | `String!`     | Event type (e.g. `components.created`)       |
| `payload`                 | `JSONObject!` | The exact payload delivered to your endpoint |
| `createdAt` / `updatedAt` | `DateTime!`   | Row timestamps                               |

{% hint style="info" %}
The `errorMessage` field is **deprecated** — it is an alias for `deliveryErrorMessage`. Use `deliveryErrorMessage` going forward.
{% endhint %}

### Examples

**Find undelivered logs across all your webhooks** (anything that hasn't received a 2xx):

```graphql
query UndeliveredLogs {
  webhooks {
    filterLogs(input: {
      and: [{ isAcknowledged: { eq: false } }]
      pagination: { first: 50, orderBy: { field: "createdAt", direction: DESC } }
    }) {
      totalCount
      edges {
        node {
          id
          webhookId
          event
          status
          attemptCount
          responseCode
          deliveryErrorMessage
          nextRetryAt
        }
      }
    }
  }
}
```

**Audit a reporting window** (all logs for one webhook in May 2026):

```graphql
query MonthlyAudit {
  webhooks {
    filterLogs(input: {
      and: [
        { webhookId: { eq: "webhook-uuid" } }
        { createdAt: { rangeStart: "2026-05-01T00:00:00Z", rangeEnd: "2026-05-31T23:59:59Z" } }
      ]
      pagination: { first: 100, orderBy: { field: "createdAt", direction: ASC } }
    }) {
      totalCount
      pageInfo { hasNextPage endCursor }
      edges { node { id event status processedState createdAt } }
    }
  }
}
```

**Find a backlog of unprocessed deliveries** (delivered to you, but your integration hasn't reported an outcome — pairs with `markProcessed`):

```graphql
query UnprocessedBacklog {
  webhooks {
    filterLogs(input: {
      and: [
        { isAcknowledged: { eq: true } }
        { processedState: { isNull: true } }
      ]
      pagination: { first: 50, orderBy: { field: "createdAt", direction: ASC } }
    }) {
      totalCount
      edges { node { id event payload createdAt } }
    }
  }
}
```

**Find deliveries your integration failed to process** (for reprocessing / dead-letter handling):

```graphql
query FailedProcessing {
  webhooks {
    filterLogs(input: {
      and: [{ processedState: { eq: FAIL } }]
      pagination: { first: 50, orderBy: { field: "updatedAt", direction: DESC } }
    }) {
      edges { node { id event processingErrorMessage processedAt payload } }
    }
  }
}
```

### Example: Monitoring Script

```javascript
// Page through every undelivered log across your webhooks.
async function findUndeliveredLogs() {
  const query = `
    query Undelivered($after: String) {
      webhooks {
        filterLogs(input: {
          and: [{ isAcknowledged: { eq: false } }]
          pagination: { first: 100, after: $after, orderBy: { field: "createdAt", direction: DESC } }
        }) {
          totalCount
          pageInfo { hasNextPage endCursor }
          edges {
            node { id webhookId event status attemptCount deliveryErrorMessage }
          }
        }
      }
    }
  `;

  const undelivered = [];
  let after = null;

  // Walk forward until there are no more pages.
  do {
    const data = await graphqlClient.request(query, { after });
    const { edges, pageInfo } = data.webhooks.filterLogs;
    undelivered.push(...edges.map((e) => e.node));
    after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
  } while (after);

  if (undelivered.length > 0) {
    console.warn(`${undelivered.length} webhook deliveries have not been acknowledged:`);
    undelivered.forEach((log) => {
      console.warn(`  - [${log.webhookId}] ${log.event}: ${log.deliveryErrorMessage ?? 'pending'} (attempt ${log.attemptCount})`);
    });
  }

  return undelivered;
}
```

***

## Tracking Processing Outcomes with `markProcessed`

A log's `status` (`DELIVERED` / `FAILED`) reflects whether **Duro** successfully delivered the HTTP POST to your endpoint and received a 2xx. It says nothing about whether **your integration** successfully *processed* the event afterward — your ERP sync might reject the part, your job queue might fail, your downstream API might be down.

`markProcessed` lets you record that application-side outcome back onto the webhook log as a `processedState` (`SUCCESS` or `FAIL`), with an optional failure reason.

{% hint style="info" %}
`markProcessed` is **optional** — webhooks work exactly the same whether or not you call it. Its value is that it pairs with `filterLogs`: once you report processing outcomes, you can query for unprocessed or failed-processing logs to build reconciliation, retry, and dead-letter workflows entirely off Duro's log store — no separate tracking database required.
{% endhint %}

### Reporting a successful outcome

```graphql
mutation MarkSuccess {
  webhooks {
    markProcessed(input: {
      id: "webhook-log-uuid"
      state: SUCCESS
    }) {
      id
      processedState
      processedAt
    }
  }
}
```

### Reporting a failure

Supply a `processingErrorMessage` so the reason is visible in later `filterLogs` queries:

```graphql
mutation MarkFailure {
  webhooks {
    markProcessed(input: {
      id: "webhook-log-uuid"
      state: FAIL
      processingErrorMessage: "ERP rejected part CMP-001234: duplicate MPN"
    }) {
      id
      processedState
      processingErrorMessage
      processedAt
    }
  }
}
```

#### Input fields (`WebhookMarkProcessedInput`)

| Field                    | Required | Description                                                                                                            |
| ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `id`                     | Yes      | The `WebhookLog` `id` to mark                                                                                          |
| `state`                  | Yes      | `SUCCESS` or `FAIL`                                                                                                    |
| `processingErrorMessage` | No       | Failure reason when `state: FAIL`. Persisted on the log; capped at 4000 characters — truncate stack traces client-side |

### Delivery status vs. processed state

These are two independent dimensions of a single log:

|                | Owned by              | Field(s)                                                  | Question it answers                                 |
| -------------- | --------------------- | --------------------------------------------------------- | --------------------------------------------------- |
| **Delivery**   | Duro                  | `status`, `isAcknowledged`, `responseCode`                | Did Duro reach your endpoint and get a 2xx?         |
| **Processing** | You (`markProcessed`) | `processedState`, `processedAt`, `processingErrorMessage` | Did your integration successfully handle the event? |

A log can be `DELIVERED` (Duro's POST succeeded) yet `processedState: FAIL` (your ERP sync threw) — that combination is exactly what reconciliation workflows look for.

### Use cases

* **Reconciliation backlog** — report `SUCCESS` after each event is fully handled, then periodically query `filterLogs(processedState: { isNull: true }, isAcknowledged: { eq: true })` to find delivered-but-not-yet-processed events your system may have dropped (a crash between receiving and processing).
* **Reprocessing / dead-letter** — on a processing failure, call `markProcessed(state: FAIL, processingErrorMessage: ...)`, then run a retry job over `filterLogs(processedState: { eq: FAIL })`.
* **Auditing & SLA** — `processedAt` gives an authoritative "when did the integration finish" timestamp alongside Duro's delivery timestamps, without standing up your own tracking store.

### Example: receive → process → report

```javascript
app.post('/webhooks/duro', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const rawBody = req.body;

  if (!verifySignature(rawBody, signature)) {
    return res.status(401).send('Unauthorized');
  }

  // Acknowledge delivery immediately (this drives Duro's DELIVERED status).
  res.status(200).send('OK');

  const payload = JSON.parse(rawBody.toString('utf8'));

  // The webhook log id is not in the payload — resolve it via filterLogs using
  // the payload's sourceId, or track your own mapping. Here we assume logId is
  // resolved out-of-band.
  const logId = await resolveLogId(payload.sourceId);

  try {
    await processWebhook(payload);
    await markProcessed(logId, 'SUCCESS');
  } catch (err) {
    // Report the application-side failure so it surfaces in filterLogs.
    await markProcessed(logId, 'FAIL', err.message);
  }
});

async function markProcessed(id, state, processingErrorMessage) {
  const mutation = `
    mutation MarkProcessed($input: WebhookMarkProcessedInput!) {
      webhooks { markProcessed(input: $input) { id processedState } }
    }
  `;
  const input = { id, state };
  if (state === 'FAIL' && processingErrorMessage) {
    input.processingErrorMessage = processingErrorMessage.slice(0, 4000);
  }
  await graphqlClient.request(mutation, { input });
}
```

***

## Common Use Cases

### ERP Integration

Sync component and BOM changes to your ERP system in real-time:

```javascript
async function syncToERP(component) {
  // Map Duro fields to your ERP schema
  const erpItem = {
    partNumber: component.cpn,
    description: component.name,
    revision: component.revision,
    status: mapStatusToERP(component.status),
  };

  // Upsert to ERP
  await erpClient.upsertItem(erpItem);
  console.log(`Synced ${component.cpn} to ERP`);
}

app.post('/webhooks/duro', async (req, res) => {
  // ... verification code ...

  res.status(200).send('OK');

  const { event, metadata } = req.body;

  if (event === 'components.created' || event === 'components.updated') {
    const component = await fetchComponentFromDuro(metadata.componentId);
    await syncToERP(component);
  }
});
```

### Slack Notifications

Alert your team about important component changes:

```javascript
const { WebClient } = require('@slack/web-api');
const slack = new WebClient(process.env.SLACK_TOKEN);

async function notifySlack(event, component) {
  const emoji = event === 'components.created' ? ':heavy_plus_sign:' : ':pencil2:';
  const action = event === 'components.created' ? 'created' : 'updated';

  await slack.chat.postMessage({
    channel: '#engineering-updates',
    text: `${emoji} Component *${component.cpn}* was ${action}`,
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `${emoji} Component *${component.cpn}* was ${action}`,
        },
      },
      {
        type: 'section',
        fields: [
          { type: 'mrkdwn', text: `*Name:*\n${component.name}` },
          { type: 'mrkdwn', text: `*Revision:*\n${component.revision}` },
          { type: 'mrkdwn', text: `*Status:*\n${component.status}` },
        ],
      },
    ],
  });
}
```

### Audit Logging

Maintain a detailed audit trail of all changes:

```javascript
async function logToAuditSystem(payload, component) {
  const auditEntry = {
    timestamp: payload.timestamp,
    eventId: payload.eventId,
    eventType: payload.event,
    resourceType: 'component',
    resourceId: payload.metadata.componentId,
    resourceCpn: component?.cpn,
    revision: payload.metadata.revisionValue,
    version: payload.metadata.version,
  };

  await auditDatabase.insert('webhook_audit_log', auditEntry);
  console.log(`Audit log created for ${payload.eventId}`);
}
```

### Change Order Workflow Tracking

Monitor change order progress and sync approval status to external systems:

```javascript
async function handleChangeOrderEvents(payload) {
  const { event, metadata, timestamp } = payload;

  switch (event) {
    case 'change_orders.opened':
      // Change order submitted for review
      await notifyReviewers(metadata.changeOrderId);
      await updateProjectManagementSystem(metadata.changeOrderId, 'in_review');
      break;

    case 'change_orders.stage_transition':
      // Track workflow progress
      console.log(`CO ${metadata.changeOrderId} moved to stage ${metadata.newStageId}`);
      await syncWorkflowStatus(metadata);
      break;

    case 'change_orders.stage_reviewer_decision':
      // Individual reviewer decision
      const action = metadata.decision === 'approved' ? 'approved' : 'rejected';
      await logReviewerAction(metadata.changeOrderId, metadata.reviewerId, action);
      break;

    case 'change_orders.resolution':
      // Final resolution - approved, rejected, or withdrawn
      if (metadata.resolution === 'approved') {
        await triggerPostApprovalWorkflow(metadata.changeOrderId);
        await notifyStakeholders(metadata.changeOrderId, 'Change order approved');
      } else if (metadata.resolution === 'rejected') {
        await notifyOwner(metadata.changeOrderId, 'Change order rejected');
      }
      break;
  }
}

async function triggerPostApprovalWorkflow(changeOrderId) {
  // Fetch full change order details
  const changeOrder = await fetchChangeOrderFromDuro(changeOrderId);

  // Sync approved components to ERP
  for (const item of changeOrder.items) {
    if (item.action === 'release') {
      await syncComponentToERP(item.component);
    }
  }

  console.log(`Post-approval workflow completed for CO ${changeOrderId}`);
}
```

***

## Troubleshooting

### Common Issues

#### Webhook not receiving events

1. **Check if webhook is enabled** - Query the webhook and verify `isEnabled: true`
2. **Verify event subscriptions** - Ensure the correct events are in the `events` array
3. **Check your endpoint** - Verify your URL is accessible from the internet
4. **Review logs** - Use the `filterLogs` query to see delivery attempts and errors (filter on `webhookId` and `isAcknowledged: { eq: false }` to surface undelivered events)

#### Signature verification failing

1. **Check the secret** - Ensure you're using the exact same `signingSecret` you configured on the webhook
2. **Use the raw body** - Signature is computed over the raw request bytes, not a re-serialized JSON object. Any middleware that parses or rewrites the body before verification will break the signature
3. **Strip the `sha256=` prefix** - The header value is `sha256=<hex>`, not a bare hex digest. Split on the first `=` and compare only the hex portion
4. **Match digest lengths first** - `crypto.timingSafeEqual` throws when buffers differ in length; guard with a length check before comparing
5. **Check encoding** - Ensure UTF-8 encoding throughout

#### Missing webhook deliveries

1. **Check retry status** - Some deliveries may be queued for retry
2. **Verify library scope** - Webhooks only fire for events in their configured library
3. **Check component filters** - Events fire for all components in the library

### Testing Your Webhook Endpoint

Before configuring a production webhook, test your endpoint:

```bash
# Send a test payload to your endpoint
curl -X POST https://your-endpoint.com/webhooks/duro \
  -H "Content-Type: application/json" \
  -H "User-Agent: Duro-Webhook-Service/1.0" \
  -d '{
    "event": "components.updated",
    "eventId": "test-event-id",
    "sourceId": "test-source-id",
    "timestamp": "2025-01-15T10:30:00.000Z",
    "metadata": {
      "componentId": "test-component-id",
      "revisionValue": "1.A",
      "version": 1
    }
  }'
```

***

## Migrating from v1 to v2

v1 webhook payloads included complete resource data. v2 uses a "Ping then Pull" pattern—you receive minimal metadata and fetch full details via GraphQL when needed.

### Event Mapping

| v1 Event       | v2 Event                  | Notes                                      |
| -------------- | ------------------------- | ------------------------------------------ |
| `co.Submitted` | `CHANGE_ORDER_OPENED`     |                                            |
| `co.Approved`  | `CHANGE_ORDER_RESOLUTION` | Check `metadata.resolution === 'approved'` |
| `co.Rejected`  | `CHANGE_ORDER_RESOLUTION` | Check `metadata.resolution === 'rejected'` |

### Example: Change Order Approved

**v1 payload** (complete data embedded):

```json
{
  "_id": "507f1f77bcf86cd799439011",
  "con": "ECO-001",
  "name": "Update PCB Design",
  "description": "Replace capacitors with higher rated components",
  "eventType": "co.Approved",
  "resolution": "APPROVED",
  "status": "CLOSED",
  "type": "ECO",
  "created": "2025-01-15T10:00:00.000Z",
  "creator": "507f1f77bcf86cd799439012",
  "firstName": "John",
  "lastName": "Doe",
  "email": "john@example.com",
  "approvalType": "STANDARD",
  "lastModified": "2025-01-15T14:00:00.000Z",
  "approverList": [
    {
      "user": "507f1f77bcf86cd799439013",
      "firstName": "Jane",
      "lastName": "Smith",
      "email": "jane@example.com",
      "action": "APPROVED",
      "performedAt": "2025-01-15T14:00:00.000Z"
    }
  ],
  "children": {
    "components": [
      { "_id": "507f1f77bcf86cd799439014", "cpn": "CMP-001234", "name": "Capacitor 100uF" }
    ],
    "products": []
  },
  "history": [
    { "action": "APPROVED", "user": "507f1f77bcf86cd799439013", "created": "2025-01-15T14:00:00.000Z" }
  ]
}
```

**v2 payload** (metadata only):

```json
{
  "event": "change_orders.resolution",
  "eventId": "b8c9d0e1-f2a3-4567-1234-890123456789",
  "sourceId": "nats-msg-66666",
  "timestamp": "2025-01-15T14:00:00.000Z",
  "metadata": {
    "changeOrderId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "resolution": "approved"
  }
}
```

### What's Not Included in v2

The following v1 fields are **not** in the v2 webhook payload. Use the [GraphQL API](#fetch-change-order-details) to retrieve this data:

| v1 Field                                    | How to Get in v2                                         |
| ------------------------------------------- | -------------------------------------------------------- |
| `con`, `name`, `description`                | Query `changeOrders.get()`                               |
| `status`, `type`, `resolution`              | Query `changeOrders.get()`                               |
| `creator`, `firstName`, `lastName`, `email` | Query `changeOrders.get()` with `createdBy` field        |
| `approverList`                              | Query `changeOrders.get()` with `reviewers` field        |
| `children.components`, `children.products`  | Query `changeOrders.get()` with `items` field            |
| `history`                                   | Query change order audit log                             |
| `created`, `lastModified`                   | Query `changeOrders.get()` with `createdAt`, `updatedAt` |

### Updating Your Handler

**v1:**

```javascript
if (payload.eventType === 'co.Approved') {
  for (const component of payload.children.components) {
    releaseToERP(component.cpn);
  }
}
```

**v2:**

```javascript
if (payload.event === 'change_orders.resolution' && payload.metadata.resolution === 'approved') {
  const changeOrder = await fetchChangeOrder(payload.metadata.changeOrderId);
  for (const item of changeOrder.items) {
    releaseToERP(item.component.cpn);
  }
}
```

{% hint style="info" %}
See [Fetch Change Order Details](#fetch-change-order-details) for the GraphQL query and [Signing Secrets](#signing-secrets) for signature verification.
{% endhint %}

***

## Next Steps

* Review [Authentication](/getting-started/authentication) for securing your API requests
* Explore [Searching and Filtering](/advanced-topics/searching-and-filtering) for advanced component queries
* Learn about [Change Orders](/core-concepts/change-orders) to understand change order workflows
* Check out [Error Handling](/advanced-topics/error-handling) for robust integration patterns


# Error Handling

Learn how to handle errors and edge cases in the Duro API.

### Error Types

The API returns different types of errors:

* Validation errors
* Authentication errors
* Authorization errors
* Entitlement (subscription plan) errors
* Rate limiting errors
* Server errors

### Error Format

Errors arrive in the standard GraphQL `errors` array. `extensions.code` is the stable, programmatic key — branch on it rather than on `message`, which may be reworded.

```json
{
  "errors": [
    {
      "message": "Unauthorized",
      "path": ["user"],
      "extensions": {
        "code": "UNAUTHENTICATED",
        "originalError": { "message": "Unauthorized", "statusCode": 401 },
        "service": "foundation"
      }
    }
  ]
}
```

There are two shapes to be aware of, and `code` is the only key present in both:

* **Framework errors** — authentication and authorization failures raised before your operation runs (`UNAUTHENTICATED`, `FORBIDDEN`). The HTTP status is nested under `extensions.originalError.statusCode`, as above.
* **Domain errors** — business-rule violations raised by the operation itself. Every code documented on this page is one of these. They carry `statusCode` directly on `extensions`, with no `originalError`:

```json
{
  "errors": [
    {
      "message": "Seat limit reached (25/25). Upgrade your plan or remove a member to add more.",
      "path": ["organization", "addMember"],
      "extensions": {
        "code": "SEAT_LIMIT_REACHED",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

`service` names the subgraph that raised the error. A few domain errors carry extra keys; those are called out alongside the code below.

### Common Error Scenarios

```json
// Rate Limiting Error
{
  "errors": [
    {
      "message": "Rate limit exceeded",
      "extensions": {
        "code": "RATE_LIMITED",
        "retryAfter": 60
      }
    }
  ]
}
```

### Validation Errors

Validation errors are returned when a mutation violates a business rule. The `extensions.code` identifies the specific rule so you can handle it programmatically.

| Code                          | Operation        | Meaning                                                                                                                                                                                                                 |
| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NRM_NOT_ALLOWED_ON_OBSOLETE` | Component update | A component cannot be marked Not Revision Managed (NRM) while it is Obsolete. Change the status away from Obsolete before enabling NRM. See [Not Revision Managed](/core-concepts/components#not-revision-managed-nrm). |

```json
// Marking an Obsolete component as NRM
{
  "errors": [
    {
      "message": "Not Revision Managed and Obsolete cannot be combined. Turn off Not Revision Managed before obsoleting this component.",
      "path": ["component", "update"],
      "extensions": {
        "code": "NRM_NOT_ALLOWED_ON_OBSOLETE",
        "statusCode": 400
      }
    }
  ]
}
```

### Change Order Type Errors

Selecting a change order [type](/core-concepts/change-orders#change-order-types) can produce validation errors when the requested type is not permitted, or when a `DCO` (Documentation Change Order) is asked to do something its revision-freeze forbids.

#### `CO_TYPE_NOT_ALLOWED`

Returned when a change order is created with a `coType` that the chosen template does not allow. A template's allowed types are declared in its `co_types` list (schema version `1.1`). The resolved type — whether passed explicitly in the create input, taken from the template's `defaultCoType`, or defaulted to `ECO` — must be a member of that list.

```json
{
  "errors": [
    {
      "message": "Change order type \"MCO\" is not allowed by this template. Allowed types: [ECO, DCO].",
      "path": ["changeOrders", "create"],
      "extensions": {
        "code": "CO_TYPE_NOT_ALLOWED",
        "statusCode": 400
      }
    }
  ]
}
```

**How to resolve:**

* Query the template's allowed types before creating and pass a `coType` that is a member of `coTypes` (or omit `coType` to fall back to `defaultCoType`):

  ```graphql
  query {
    changeOrders {
      getTemplates { id name coTypes defaultCoType }
    }
  }
  ```
* Legacy (`1.0`) templates return `null` for `coTypes` and allow only `ECO`. To offer additional types, migrate the template to `version: "1.1"` — see the [Workflow YAML Reference](/library-configuration/change-order-workflow-reference#change-order-types).

{% hint style="info" %}
This check is enforced atomically against the template at create time, so a concurrent edit that removes a type in the same instant cannot let a disallowed type slip through — you will consistently get `CO_TYPE_NOT_ALLOWED` rather than a change order that outlives its template's rules.
{% endhint %}

#### `DCO_STATUS_CHANGE_NOT_ALLOWED` / `DCO_REVISION_CHANGE_NOT_ALLOWED`

A change order of type `DCO` is **revision-frozen**: it never bumps a component's revision and cannot change a component's `status` or `revision`. These codes are raised whenever a DCO would result in such a change. If the change genuinely needs to alter a component's status or revision, use a non-`DCO` type. See [Change Order Types](/core-concepts/change-orders#change-order-types).

They surface in three situations:

* **Proposed change.** Proposing a status change or a revision change directly on a DCO item is rejected.
* **Baseline drift at submit.** The built-in [DCO baseline validation](/library-configuration/change-order-validations#dco-baseline-freeze) blocks the change order when any item's live `status` or `revision` has drifted from its last-released baseline — for example because a concurrent change order released a new revision of that item after it was added to the DCO.
* **Release-time abort at close.** The release path re-checks the baseline when the DCO closes and aborts with these codes if drift is detected then, or if a DCO item has no releasable baseline or its live component can't be found.

### Organization Creation Errors

Creating a new production organization is gated: the account must hold a valid, unused organization-creation grant issued to its verified email address. Self-serve creation without a grant is not yet available. When a create is refused, `extensions.code` identifies why so you can present a clear, distinguishable message. Query [`Me.canCreateOrg`](/getting-started/current-user#checking-whether-a-user-can-create-an-organization) beforehand to know whether to offer the action at all.

The codes below apply to the grant path. Creating a **sandbox** organization under an existing parent goes through a different set of checks — see [Sandbox Organization Errors](#sandbox-organization-errors).

All five are `403`.

| Code                             | Meaning                                                                                                                                                                                                                                                                                                                                                                | How to resolve                                                                                               |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `GRANT_NONE`                     | **The refusal you should expect in practice.** No currently claimable grant was found for the account. A grant that was never issued, has already been consumed, was revoked, has expired, or whose backing package is archived all collapse into this one code — the message is deliberately generic and never reveals whether a grant exists for some other account. | Request a grant from Duro before creating an organization.                                                   |
| `SELF_SERVE_NOT_AVAILABLE`       | The account has no claimable grant **but is on the self-serve allowlist** — it would be eligible to create its own organization, except self-serve creation has not shipped yet. It is the more specific refusal for accounts that would otherwise be permitted, not a broader one.                                                                                    | Request an organization-creation grant from Duro in the meantime.                                            |
| `GRANT_ALREADY_CONSUMED`         | **Race-only.** The grant was pending when it was read, but a concurrent create claimed it first. A grant that was already consumed before the request began returns `GRANT_NONE` instead.                                                                                                                                                                              | Treat as a lost race. Do not retry — the grant is spent. Request a new grant to create another organization. |
| `GRANT_REVOKED`                  | **Race-only.** The grant was revoked in the instant between being read and being claimed. A grant revoked before the request began returns `GRANT_NONE` instead.                                                                                                                                                                                                       | Contact Duro to have a new grant issued.                                                                     |
| `GRANT_BACKING_PACKAGE_ARCHIVED` | **Defensive guard; not reachable on the normal path.** Grants whose backing package is archived are filtered out before the claim, so they surface as `GRANT_NONE`.                                                                                                                                                                                                    | Contact Duro; the grant must be re-issued against an active package.                                         |

```json
{
  "errors": [
    {
      "message": "No valid pending grant was found for this account.",
      "path": ["organization", "create"],
      "extensions": {
        "code": "GRANT_NONE",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

{% hint style="warning" %}
Do not branch on `GRANT_ALREADY_CONSUMED` or `GRANT_REVOKED` to detect a spent or revoked grant — those two codes are raised **only** when the state changes mid-request, under concurrency. In the ordinary sequential case, a spent, revoked, or expired grant is indistinguishable from no grant at all and returns `GRANT_NONE`. Handle `GRANT_NONE` as the general "you cannot create an organization" case and treat the other two as rare race outcomes.
{% endhint %}

{% hint style="info" %}
[`Me.canCreateOrg`](/getting-started/current-user) is a UI hint that reflects eligibility at query time. Because a grant can be consumed or revoked in the interim, treat the create mutation and these error codes as the source of truth.
{% endhint %}

### Sourcing Import Errors

Columns mapped to a [`src:` target](/core-concepts/importing-components#sourcing-columns-src-targets) are applied after the component rows are written. When a sourcing value cannot be applied, the failure is reported against the row with one of these codes.

| Code                                     | Target            | Meaning                                                                        | How to resolve                                                    |
| ---------------------------------------- | ----------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
| `SOURCING_IMPORT_PACKAGE_TYPE_UNKNOWN`   | `src:packageType` | The package type in the cell does not match any package type Duro recognizes.  | Correct the spelling, or use one of the recognized package names. |
| `SOURCING_IMPORT_PACKAGE_TYPE_AMBIGUOUS` | `src:packageType` | The value matches more than one package type, so the import cannot choose one. | Use the exact, unambiguous package name.                          |

```json
{
  "rowNumber": 7,
  "outcome": "FAILED_PROMOTION",
  "errors": [
    {
      "field": "src:packageType",
      "code": "SOURCING_IMPORT_PACKAGE_TYPE_UNKNOWN",
      "message": "Unknown package type \"SOIC8-W\""
    }
  ]
}
```

### Entitlement Errors

Some features and limits are governed by your organization's **subscription plan**. When a request targets a feature your plan does not include, or would exceed a quota your plan allows, the API returns an entitlement error. Match on `extensions.code` — it is the stable, programmatic key.

| Code                        | Meaning                                                                                                                                                                                                                                                                                | How to resolve                                                                     |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `FEATURE_NOT_IN_PLAN`       | The operation requires a feature that your organization's plan does not include. For example, configuring [SAML SSO](/advanced-topics/saml-sso-setup) requires the `SSO_SAML` entitlement and [SCIM provisioning](/advanced-topics/scim-provisioning) requires the `SCIM` entitlement. | Contact your Duro account team to add the feature to your plan.                    |
| `SEAT_LIMIT_REACHED`        | The operation would exceed the number of user seats your plan allows.                                                                                                                                                                                                                  | Remove unused members, or contact your Duro account team to raise your seat count. |
| `ENTITLEMENT_LIMIT_REACHED` | The operation would exceed another quota your plan allows (storage or sandbox organizations, for example). The message names the specific limit, and `extensions.entitlement` carries its machine key.                                                                                 | Reduce usage below the limit, or contact your Duro account team to raise it.       |

All three are `403`. The messages interpolate the specific feature or counts, so branch on `code` — and, for `ENTITLEMENT_LIMIT_REACHED`, on `extensions.entitlement`.

```json
// Configuring SAML on an organization whose plan lacks the SSO_SAML entitlement
{
  "errors": [
    {
      "message": "SSO / SAML is not included in your plan.",
      "path": ["organization", "configureSaml"],
      "extensions": {
        "code": "FEATURE_NOT_IN_PLAN",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

```json
// Exceeding a quota — here, the sandbox_orgs limit on the parent organization
{
  "errors": [
    {
      "message": "Sandbox organizations limit reached (3/3). Upgrade your plan to increase this limit.",
      "path": ["organization", "create"],
      "extensions": {
        "code": "ENTITLEMENT_LIMIT_REACHED",
        "entitlement": "sandbox_orgs",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

{% hint style="info" %}
Entitlement enforcement applies to organizations with a subscription. These errors surface the limit that was hit so you can act on it — key on `extensions.code` rather than the human-readable `message`, which may change.
{% endhint %}

### Sandbox Organization Errors

[Sandbox organizations](/getting-started/api-v2-migration#sandbox-organizations) are metered against the parent organization's `sandbox_orgs` entitlement. Pass `parentOrganizationId` to `organization.create` to request one. These codes can come back when a sandbox operation is refused.

| Code                        | Status | Operation                                 | Meaning                                                                                                                                                                                                                                                                                                            |
| --------------------------- | ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `SANDBOX_PARENT_FORBIDDEN`  | 403    | Create with `parentOrganizationId`        | The chosen parent cannot host the sandbox. The parent must exist, be an active production (non-sandbox) organization that you are the **Site Admin** of, and have a subscription. Sandboxes cannot be nested, so a sandbox can never be a parent. Being an Org Admin rather than the Site Admin is not sufficient. |
| `SANDBOX_NAME_TAKEN`        | 409    | Create with `parentOrganizationId`        | A sandbox reuses its parent's company, so its name must be unique alongside the parent and every sibling sandbox. Unlike the other three, this one is user-fixable: prompt for a different name.                                                                                                                   |
| `SANDBOX_NOT_AVAILABLE`     | 409    | Create with `parentOrganizationId`        | Sandbox creation is not currently enabled. Refresh your creation options and retry — the request is refused rather than silently falling through to the grant path, so no grant is consumed.                                                                                                                       |
| `SANDBOX_ARCHIVE_FORBIDDEN` | 403    | `organization.archiveSandboxOrganization` | The target cannot be archived through this mutation. It only archives sandbox organizations you administer — production organizations, and sandboxes you do not administer, are rejected.                                                                                                                          |

{% hint style="warning" %}
Exceeding the parent's sandbox quota does **not** return `SANDBOX_PARENT_FORBIDDEN`. It returns [`ENTITLEMENT_LIMIT_REACHED`](#entitlement-errors) with `extensions.entitlement` set to `sandbox_orgs`. `SANDBOX_PARENT_FORBIDDEN` covers only eligibility of the parent and your role on it.
{% endhint %}

`SANDBOX_PARENT_FORBIDDEN` and `SANDBOX_ARCHIVE_FORBIDDEN` are deliberately generic: several distinct causes collapse into one message so the response never reveals whether a given organization exists or why exactly it was refused. Do not try to infer the specific cause from them.

```json
// Creating a sandbox under an ineligible parent
{
  "errors": [
    {
      "message": "You cannot create a sandbox organization under that parent.",
      "path": ["organization", "create"],
      "extensions": {
        "code": "SANDBOX_PARENT_FORBIDDEN",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

```json
// Archiving an organization that is not an archivable sandbox
{
  "errors": [
    {
      "message": "You cannot archive that sandbox organization.",
      "path": ["organization", "archiveSandboxOrganization"],
      "extensions": {
        "code": "SANDBOX_ARCHIVE_FORBIDDEN",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

{% hint style="info" %}
Before offering a "create sandbox" action, query `orgCreationOptions` to check whether the user can create a sandbox and which parent organizations still have headroom. See [Sandbox Organizations](/getting-started/api-v2-migration#sandbox-organizations).
{% endhint %}

### Best Practices

* Implement proper error handling
* Add retry logic for rate limits
* Log errors for debugging
* Handle network timeouts
* Provide user-friendly error messages

### Error Recovery

```typescript
async function queryWithRetry(query: string, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await executeQuery(query);
    } catch (error) {
      if (!isRetryableError(error) || attempt === maxRetries) {
        throw error;
      }
      await delay(exponentialBackoff(attempt));
    }
  }
}
```

### Next Steps

Join our [Developer Community](/community/developer-community) for support and discussions.


# SAML SSO Setup

Enable enterprise Single Sign-On (SSO) for your Duro organization using SAML 2.0 with popular identity providers like Google Workspace, Microsoft Entra ID, Okta, and others.

{% hint style="info" %}
This guide covers the complete SAML integration workflow including Auth0 configuration, Identity Provider setup, and Duro organization settings.
{% endhint %}

## Overview

SAML (Security Assertion Markup Language) allows your users to authenticate using your company's existing identity provider, providing centralized access control and enhanced security through features like multi-factor authentication.

### Authentication Flow

1. User enters their organization identifier on the Duro login page
2. Duro redirects to Auth0 with the organization's SAML connection
3. Auth0 redirects to your Identity Provider (Google, Entra ID, etc.)
4. User authenticates with corporate credentials
5. IdP sends SAML assertion back to Auth0
6. Auth0 returns user to Duro, fully authenticated

### Prerequisites

Before starting, ensure you have:

* Administrative access to your Identity Provider (Google Workspace, Microsoft Entra ID, etc.)
* Auth0 tenant credentials (contact your Duro technical team)
* PostHog access (for Duro internal team to enable feature flag)
* Duro organization admin access (Site Admin role required)
* A subscription plan that includes the `SSO_SAML` entitlement. Saving SAML configuration on an organization whose plan lacks it fails with `FEATURE_NOT_IN_PLAN` — contact your Duro account team to enable it. See [Entitlement Errors](/advanced-topics/error-handling#entitlement-errors).

## Quick Start

### Phase 1: Create Auth0 Application

**Duration**: 5-10 minutes

First, create a Single Page Application in your Auth0 tenant:

1. Navigate to **Applications** → **Applications** in Auth0 Dashboard
2. Click **Create Application**
3. Select **Single Page Application** type
4. Configure the allowed URLs:

```
Allowed Callback URLs:
http://localhost:5173/callback,
https://your-duro-domain.com/callback

Allowed Logout URLs:
http://localhost:5173,
https://your-duro-domain.com

Allowed Web Origins:
http://localhost:5173,
https://your-duro-domain.com
```

{% hint style="warning" %}
Replace `your-duro-domain.com` with your actual Duro installation domain.
{% endhint %}

### Phase 2: Create SAML Connection

**Duration**: 5 minutes

Create a SAML connection in Auth0 **before** configuring your Identity Provider:

1. Go to **Authentication** → **Enterprise** → **SAML**
2. Click **Create Connection**
3. Choose a descriptive name (e.g., `acmecorp-saml`)
4. **Copy the Service Provider details** - Auth0 displays:
   * **ACS URL**: `https://{tenant}.auth0.com/login/callback?connection={name}`
   * **Entity ID**: `urn:auth0:{tenant}:{connection-name}`

These values are automatically generated based on your Auth0 tenant and connection name. You'll need them in the next step.

### Phase 3: Configure Your Identity Provider

#### Google Workspace

**Duration**: 10-15 minutes

1. **Access Google Admin Console** at [admin.google.com](https://admin.google.com)
2. Go to **Apps** → **Web and mobile apps** → **Add App** → **Add custom SAML app**
3. Set app name (e.g., "Duro") and click **Continue**
4. **Download IdP Information**

   Google displays your IdP details. You'll use these in Auth0 later.

   * **Download Metadata**: Click to download the XML metadata file

   OR manually note the following values: (protip: these values look nearly identical but are different)

   * **SSO URL**: `https://accounts.google.com/o/saml2/idp?idpid=XXXXXXXXX`
   * **Entity ID**: `https://accounts.google.com/o/saml2?idpid=XXXXXXXXX`
   * **Certificate**: Download the `.pem` or `.crt` file
   * Click **Continue**
5. **Service Provider Details**

   Use the values you copied from Auth0 in Phase 2:

   * **ACS URL**: Paste the ACS URL from Auth0
     * Example: `https://duro-dev.us.auth0.com/login/callback?connection=google-saml`
   * **Entity ID**: Paste the Entity ID from Auth0
     * Example: `urn:auth0:duro-dev:google-saml`
   * **Name ID format**: Select `EMAIL`
   * **Name ID**: Select `Basic Information > Primary email`
   * Click **Continue**
6. **Attribute Mapping**
   * You can skip this step and just click **Finish**
7. **Enable the App**
   * You'll see the app in your Web and mobile apps list with status "OFF for everyone"
   * Click on the app name
   * Click **User access**
   * Select **ON for everyone** (or choose specific organizational units)
   * Click **Save**
8. **Verify App Status**
   * The app should now show "ON for everyone" (or your selected OUs)
   * Changes may take a few minutes to propagate

{% hint style="info" %}
The SSO URL and Entity ID from Google look similar but are different - one has `/idp` and one has just `/saml2`. Make sure to copy the correct values.
{% endhint %}

#### Microsoft Entra ID (Azure AD)

**Duration**: 10-15 minutes

1. **Access Entra Admin Center**
   * Navigate to [entra.microsoft.com](https://entra.microsoft.com)
   * Sign in with your Microsoft admin account
2. **Create Enterprise Application**
   * Go to **Identity** → **Applications** → **Enterprise applications**
   * Click **New application**
   * Click **Create your own application**
   * **Name**: `Duro`
   * Select **Integrate any other application you don't find in the gallery (Non-gallery)**
   * Click **Create**
3. **Assign Users**
   * Go to **Users and groups** in the left sidebar
   * Click **Add user/group**
   * Select users or groups that should have access
   * Click **Assign**
4. **Configure SAML**

   * Go to **Single sign-on** in the left sidebar
   * Select **SAML**
   * Click **Edit** on **Basic SAML Configuration**

   **Enter Service Provider details from Auth0**:

   * **Identifier (Entity ID)**: Paste Entity ID from Auth0
     * Example: `urn:auth0:duro-dev:google-saml`
   * **Reply URL (Assertion Consumer Service URL)**: Paste ACS URL from Auth0
     * Example: `https://duro-dev.us.auth0.com/login/callback?connection=google-saml`
   * **Sign on URL**: Same as Reply URL
   * Click **Save**
5. **Download Certificate and Copy URLs**
   * Go back to the SAML configuration page
   * Under **SAML Certificates**, download **Certificate (Base64)**
   * Under **Set up Duro**, copy:
     * **Login URL** (this is your SSO URL)
     * **Microsoft Entra Identifier** (Entity ID)
     * **Logout URL** (optional)
6. **Save Configuration**
   * Keep these values for the next phase

### Phase 4: Complete Auth0 Configuration

**Duration**: 5 minutes

Return to your SAML connection in Auth0:

1. Navigate back to **Authentication** → **Enterprise** → **SAML**
2. Click on your connection name
3. **Enter IdP details**:
   * Sign In URL: The SSO URL from your IdP
   * Upload or paste the X509 Signing Certificate
   * Protocol Binding: `HTTP-POST` (default)
4. Click **Save Changes**
5. Go to the **Applications** tab within your SAML connection
6. Find your Duro application (created in Phase 1)
7. Navigate to the *Connections* tab
8. **Toggle ON** to enable this connection for the application

### Phase 5: Enable Feature Flag

**Duration**: 2-3 minutes

**Note**: This step is typically performed by the Duro internal technical team.

The Duro technical team (or your on-prem administrator) will enable the `samlAuthentication` feature flag in PostHog for your organization.

### Phase 6: Configure SAML in Duro Organization Settings

**Duration**: 2 minutes

This is the final step, performed by a Duro organization administrator.

1. **Sign In to Duro**
   * Navigate to your Duro installation
   * Sign in with an account that has **SITE Admin** role
   * You must sign in using traditional email/password or Google SSO (not SAML yet)
2. **Navigate to Organization Settings**
   * Go to your organization settings page:
     * Format: `https://{your-duro-domain}/org/@{company-org-slug}/settings/authentication`
     * Example: `https://duro.example.com/org/@acmecorp/settings/authentication`
3. **Enable SAML SSO**

   You should see a "SAML Configuration" section

   * **Toggle ON** the "Enable SAML SSO" switch
   * **Auth0 SAML Connection Name**: Enter the exact connection name from Phase 2
     * Example: `google-saml` or `acmecorp-saml`
     * This MUST match the connection name in Auth0 exactly (case-sensitive)
   * **Enforce SAML** (Optional):
     * Toggle **ON** if you want to require all users to authenticate via SAML
     * Toggle **OFF** to allow both SAML and traditional login methods
     * Recommended: Leave OFF initially for testing
4. **Save Configuration**
   * Click **Save** or **Update Settings**
   * You should see a success message

{% hint style="warning" %}
Saving SAML configuration (the `configureSaml` mutation) requires your organization's plan to include the `SSO_SAML` entitlement. If it does not, the save is rejected with a `FEATURE_NOT_IN_PLAN` error — contact your Duro account team to add SAML SSO to your plan. See [Entitlement Errors](/advanced-topics/error-handling#entitlement-errors).
{% endhint %}

5. **Verify Configuration**
   * The page should display:
     * ✅ SAML SSO Enabled
     * Connection name: `{your-connection-name}`
     * Enforce SAML: \[Your setting]

## Testing Your Setup

Before announcing to users, thoroughly test the SAML flow:

### Test Checklist

1. **Open incognito/private browser** (ensures clean session)
2. **Navigate to Duro** and click "Sign in with SSO"
3. **Enter organization slug** (e.g., `acmecorp`)
4. **Verify redirect chain**:
   * Redirects to Auth0
   * Redirects to your IdP (Google/Entra)
   * Redirects back to Duro
5. **Authenticate** with test user credentials
6. **Verify user profile**:
   * Name and email populated correctly
   * User is member of correct organization
   * Session persists on page refresh
7. **Test logout** functionality

{% hint style="info" %}
SAML configuration changes require Site Admin permissions and are typically managed through the Duro UI for security reasons.
{% endhint %}


# SCIM Provisioning

Automatically provision and deprovision users and teams in your Duro organization from your identity provider (IdP) using SCIM 2.0, keeping Duro in lock-step with the source of truth for your workforce.

{% hint style="info" %}
SCIM provisioning is an enterprise capability that works alongside [SAML SSO](/advanced-topics/saml-sso-setup). SAML answers *"who is this person and may they log in?"* SCIM answers *"which users and teams exist, and who belongs to them?"*
{% endhint %}

## Overview

SCIM (System for Cross-domain Identity Management) 2.0 lets your IdP — Okta, Microsoft Entra ID, JumpCloud, OneLogin, and others — push user and group changes to Duro automatically. When someone joins a group in your IdP, they appear on the corresponding Duro team. When they leave the company, their Duro access is revoked without a manual step.

Duro exposes a standard SCIM 2.0 endpoint at a dedicated per-tenant SCIM host — `https://scim-<tenant>.durohub.com/scim/v2` — which is separate from the API host you use for GraphQL. Your IdP authenticates to it with a bearer token that is pinned to a single organization.

### Two independent rails

SCIM and SAML SSO are separate integrations that happen to share an IdP. Keep them straight:

* **SAML SSO** handles authentication. It runs IdP → Auth0 → Duro. See [SAML SSO Setup](/advanced-topics/saml-sso-setup).
* **SCIM** handles provisioning. Your IdP calls Duro's `/scim/v2` endpoint directly, authenticated by a bearer token. Auth0 is **not** in the SCIM path.

### What SCIM manages (and what it doesn't)

| SCIM provisions                    | Duro manages                                           |
| ---------------------------------- | ------------------------------------------------------ |
| Users (create, update, deactivate) | Roles on teams (organization role + library overrides) |
| Groups → Teams                     | Which teams get which access                           |
| Group membership → Team membership | —                                                      |

{% hint style="warning" %}
**Roles are always assigned in Duro, never by the IdP.** SCIM provisions team *membership* only. Duro intentionally does not consume the SCIM `roles` attribute. After a team is provisioned, a Duro administrator assigns its organization role and any per-library overrides. See [Role-Based Access Control](/advanced-topics/rbac#team-based-access-control) for how team roles resolve into effective access.
{% endhint %}

## Prerequisites

Before enabling SCIM, ensure you have:

* **SAML SSO already enabled for the organization.** SCIM builds on the SAML identity foundation — enabling SCIM without SAML is rejected. Complete [SAML SSO Setup](/advanced-topics/saml-sso-setup) first.
* **Duro organization admin access** (Site Admin role) with permission to manage authentication settings.
* **Administrative access to your IdP** to configure a SCIM provisioning app.
* **A subscription plan that includes the `SCIM` entitlement.** Enabling SCIM on an organization whose plan lacks it fails with `FEATURE_NOT_IN_PLAN` — contact your Duro account team to enable it. See [Entitlement Errors](/advanced-topics/error-handling#entitlement-errors).

{% hint style="info" %}
Because SCIM depends on SAML, **disabling SAML automatically disables SCIM** as well. The two are linked as a symmetric cascade — you cannot have SCIM active without SAML active.
{% endhint %}

## Quick Start

1. **Enable SAML SSO** for the organization (see [SAML SSO Setup](/advanced-topics/saml-sso-setup)).
2. **Enable SCIM Provisioning** in Org Settings → Authentication.
3. **Mint a bearer token** — copy it immediately, it is shown only once.
4. **Point your IdP** at the SCIM base URL and paste in the bearer token.
5. **Map IdP groups to Duro teams** by assigning groups to the provisioning app.
6. **Assign each team's role** (organization role + library overrides) in Duro.

The sections below walk through each phase.

### Phase 1: Enable SCIM in Duro

**Duration**: 2 minutes

1. Sign in to Duro with an account that has the **Site Admin** role.
2. Navigate to your organization's authentication settings:
   * Format: `https://{your-duro-domain}/org/@{company-org-slug}/settings/authentication`
3. Confirm **SAML SSO** is already enabled. If it is not, enable it first.
4. Toggle **ON** the **SCIM Provisioning** setting.

{% hint style="warning" %}
Enabling SCIM (the `configureScim` mutation) requires your organization's plan to include the `SCIM` entitlement. If it does not, the request is rejected with a `FEATURE_NOT_IN_PLAN` error — contact your Duro account team to add SCIM to your plan. See [Entitlement Errors](/advanced-topics/error-handling#entitlement-errors).
{% endhint %}

### Phase 2: Mint a bearer token

**Duration**: 2 minutes

With SCIM enabled, generate the token your IdP will use to authenticate.

1. In the SCIM section of the authentication settings, click **Generate Token**.
2. **Copy the token immediately.** It is displayed only once and cannot be retrieved again.
3. Store it securely — you will paste it into your IdP in the next phase.

{% hint style="warning" %}
An organization has a **single active SCIM token** at a time. To rotate the token, generate a new one, update your IdP with it, then revoke the old one. Minting the new token first avoids an interruption in provisioning.
{% endhint %}

The token is pinned to the organization it was minted in — it can only ever read or write that one organization's users and teams.

### Phase 3: Configure your IdP

**Duration**: 5-10 minutes

In your IdP's provisioning configuration for the Duro application, set:

| Field                 | Value                                                                                                           |
| --------------------- | --------------------------------------------------------------------------------------------------------------- |
| SCIM version          | **SCIM 2.0**                                                                                                    |
| Authentication method | **HTTP Header / Bearer token**                                                                                  |
| SCIM base URL         | Your tenant's dedicated SCIM host — for example `https://scim-<tenant>.durohub.com/scim/v2` (no trailing slash) |
| Bearer token          | The token from Phase 2                                                                                          |

{% hint style="info" %}
Your exact SCIM host is specific to your Duro tenant — it takes the form `scim-<tenant>.durohub.com` and is **not** the API host used for GraphQL. If you are unsure of it, your Duro technical contact will confirm it. It always ends in `/scim/v2`.
{% endhint %}

Use your IdP's **Test Connection** action to confirm reachability. On success, activate provisioning. Most IdPs then run an initial sync of every user and group already assigned to the app.

### Phase 4: Map groups to teams and assign roles

**Duration**: varies

1. **Assign groups** to the Duro provisioning app in your IdP. Each assigned group is provisioned to Duro as a **team**, with its members added as team members.
2. **Assign roles in Duro.** For each provisioned team, a Duro administrator sets the team's organization role and any per-library overrides in Duro. This step is never performed by the IdP.

## Groups Map to Teams

A SCIM Group corresponds one-to-one with a Duro **Team**. Group membership drives team membership through standard SCIM operations:

| SCIM operation                                        | Duro effect                                                         |
| ----------------------------------------------------- | ------------------------------------------------------------------- |
| **Create Group** (`POST /Groups`)                     | Creates a new team                                                  |
| **Add / remove members** (`PATCH /Groups`)            | Adds or removes those users from the team                           |
| **Replace members** (`PUT /Groups`)                   | Replaces the team's entire membership                               |
| **Delete Group** (`DELETE /Groups`)                   | Archives the team                                                   |
| **Deactivate user** (`PATCH /Users`, `active: false`) | Removes the user from all teams, revoking their organization access |

Once a team exists, assign its organization role and library overrides in Duro. See [Team-Based Access Control](/advanced-topics/rbac#team-based-access-control) for how those roles resolve into a member's effective permissions.

### SCIM-managed teams are locked in the UI

To prevent drift between Duro and your IdP, teams that were provisioned via SCIM are **locked from manual structural edits** in the Duro UI. The IdP remains the single source of truth for a SCIM-managed team's shape.

| Locked (managed by the IdP)     | Allowed (managed in Duro)              |
| ------------------------------- | -------------------------------------- |
| Renaming the team               | Assigning the team's organization role |
| Adding or removing members      | Assigning per-library role overrides   |
| Archiving or restoring the team | Adjusting the team's library access    |

This split keeps the two systems from fighting each other: your IdP owns *who is on the team*, and Duro owns *what the team can do*.

## Deprovisioning

When a user is deactivated in your IdP — or removed from every group assigned to Duro — SCIM removes their team memberships. Because organization access flows through teams, removing those memberships revokes the user's access.

{% hint style="info" %}
**The user account persists after deprovisioning.** Rather than deleting the account, Duro retains it in a dormant state so historical records — authorship, change orders, comments — stay intact and attributable. Reactivating the user in your IdP restores their team memberships and access.
{% endhint %}

## Live Enforcement

The SCIM-enabled state is enforced **live, per request**. If SCIM is disabled for the organization — either directly, or as a cascade from disabling SAML — the bearer token stops authenticating immediately. Subsequent IdP requests receive `401 Unauthorized`.

## Testing Your Setup

1. **Confirm SAML is enabled** and SCIM shows as enabled in Org Settings → Authentication.
2. **Run your IdP's Test Connection** — it should succeed against the `/scim/v2` base URL.
3. **Assign a test user** to the Duro app in your IdP and confirm they appear as an active user in Duro.
4. **Assign a group with members** and confirm a matching team is created in Duro with the expected members.
5. **Assign a role to the team in Duro** and confirm a member's effective access reflects it.
6. **Deactivate the test user** in your IdP and confirm their access is revoked while the account remains.

## Troubleshooting

{% hint style="warning" %}
**IdP provisioning suddenly returns `401 Unauthorized`.** This is expected if SCIM was disabled, or if SAML was disabled (which cascades to disable SCIM). Re-enable SAML, then re-enable SCIM, and mint a fresh token if the old one was revoked. Token authentication is evaluated on every request, so the change takes effect immediately.
{% endhint %}

**Test Connection fails before any users sync**

* Verify the SCIM base URL ends in `/scim/v2` with no trailing slash.
* Confirm the bearer token was copied correctly and has not been revoked or rotated out.
* Confirm SCIM (and SAML) are both enabled for the organization.

**A team can't be renamed or edited in Duro**

* SCIM-managed teams are intentionally locked from structural edits. Make membership and naming changes in your IdP; assign roles and library access in Duro.

**A provisioned user can log in but sees no organization access**

* Access flows through teams. Confirm the user is a member of at least one provisioned team, and that the team has been assigned a role in Duro.

### Next Steps

Learn how team roles combine with a user's direct roles in [Role-Based Access Control](/advanced-topics/rbac#team-based-access-control).


# Introduction to Duro's Config System

## Evolution of Duro's Configuration

Duro started as an "out of the box" PLM solution that worked well for hardware teams transitioning from spreadsheets. However, as organizations scaled and required deeper customization, we recognized the need for a more flexible approach. Instead of maintaining hard-coded rules, we developed a powerful configuration system that puts control in our users' hands.

## The YAML-Based Configuration

Our "YAML all the things" philosophy enables extensive customization while maintaining Duro's core value of simplicity. Using YAML's human-readable format, you can configure:

* Category definitions and specifications
* Data validation rules
* Custom revision and status workflows
* Configurable part numbering schemes
* Change order validation and approval flows
* Event-driven notifications and webhooks
* And more...

```yaml
# Example: Simple category definition
categories:
  - code: "920"
    type: ASSEMBLY
    name: Cable Assembly
    specs:
      - name: Length
        type: string
        required: true
        validation:
          pattern: "^\\d+(\\.\\d+)?\\s*(mm|m)$"
```

## Why YAML?

We chose YAML for its:

* **Readability**: Clean, intuitive syntax that's easy to understand
* **Accessibility**: Approachable for both technical and non-technical users
* **Flexibility**: Supports complex configurations without overwhelming complexity
* **Industry adoption**: Widely used in tools like GitHub Actions and Kubernetes

Our configuration system draws inspiration from proven approaches like [GitHub Actions](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions), [Render.com Blueprints](https://render.com/docs/blueprint-spec), and [JSON Schema](https://json-schema.org/), combining their best aspects into a cohesive configuration definition system for your product data.

## Getting Started

If you're new to YAML, we recommend these resources:

* [YAML Syntax Overview](https://yaml.org/spec/1.2.2/)
* [Learn YAML in 10 minutes](https://learnxinyminutes.com/docs/yaml/)
* [YAML Tutorial by CloudBees](https://www.cloudbees.com/blog/yaml-tutorial-everything-you-need-get-started)

The following sections will guide you through configuring various aspects of your Duro environment, starting with the Category Registry.


# Category Registry

## Overview

The category & specs registry is a powerful configuration system that helps you organize and validate your product data. It enables you to:

* Define component categories with standardized attributes and specifications
* Import pre-built category templates from Duro's standard library
* Create custom validation rules and constraints for your parts data
* Establish consistent part numbering schemes across your library
* Enforce data quality standards through automated validation

This registry serves as the foundation for maintaining clean, well-structured product data that can scale with your organization.

## Schema

The category & specs registry is defined in a YAML file that is used to define the relationships between your part specifications and their constraints (ie. validation rules).

As a Library Registry Maintainer, you'll have the ability to define, override, and extend the structure of your library parts, and author custom validations on the specification values directly or in relation to other specifications.

### Import Default Category Sets

Import pre-defined category sets from Duro to leverage existing definitions.

```yaml
uses:
  - duro/mechanical-categories@v1
  - duro/electrical-categories@v1.0.1
```

### Exclude Specific Categories

Selectively exclude specific categories from imported sets that aren't relevant to your library.

```yaml
excludes:
  - "Basic Electrical Component"  # Excludes category with name "Basic Electrical Component"
  - "Capacitance"                # Excludes category with name "Capacitance"
```

### Define Common Specifications

Create reusable specifications that can be referenced across multiple categories. Common specs are organized in a hierarchical structure:

```yaml
commonSpecs:
  dimensions:
    standard_length:
      name: "Length"
      type: "string"
      validation:
        pattern: "^\\d+(\\.\\d+)?\\s*(mm|cm|m|in)$"
        description: "Must be a number followed by a valid unit"

    standard_width:
      name: "Width"
      type: "string"
      validation:
        pattern: "^\\d+(\\.\\d+)?\\s*(mm|cm|m|in)$"
        description: "Must be a number followed by a valid unit"
```

### Define Custom Categories

Create new categories with specific attributes and specifications.

```yaml
categories:
  - code: "920"
    type: ASSEMBLY
    name: Cable Assembly
    shortName: CABLE
    unitOfMeasure: EACH
    specs:
      - $ref: "#/commonSpecs/dimensions/standard_length"
        required: "*"

      - name: Conductors
        type: integer
        required: "*"
        validation:
          minimum: 1
          maximum: 100
          description: "Number of conductors (1-100)"
```

### Define Category Type Specifications

Apply common specifications across all categories of a specific type. This helps maintain consistency and reduces repetition in your category definitions.

```yaml
categoryTypeSpecs:
  ASSEMBLY:
    - $ref: "#/commonSpecs/dimensions/*"  # Reference all dimension specs
      required: "Prototype"
    - $ref: "#/commonSpecs/physical/weight"
      required: Production
      severity: Warning
  ELECTRICAL:
    - $ref: "#/commonSpecs/electrical/*"  # Reference all electrical specs
    - $ref: "#/commonSpecs/quality/iso_certification"
      required: Production
```

You can:

* Reference entire specification groups using wildcards (`/*`)
* Reference individual specifications
* Set default required stages and severity levels
* Apply specifications across all categories of a specific type

This is particularly useful when:

* You want to enforce standard specifications across category types
* You need to maintain consistent validation rules
* You want to reduce repetition in your category definitions

### Implement Validation Rules

Define validation rules for specifications using different data types and formats.

```yaml
specs:
  - name: Voltage
    type: string
    validation:
      pattern: "^\\d+(\\.\\d+)?\\s*V$"
      description: "Must be a number followed by V (e.g., 5V, 3.3V)"

  - name: PPAPLevel
    type: integer
    validation:
      minimum: 1
      maximum: 5
      description: "Production Part Approval Process level (1-5)"

  - name: Material
    type: string
    validation:
      enum: [Plastic, Metal, Glass, Ceramic, Wood, Composite]
      description: "Must be one of the specified materials"
```

### Specify Lifecycle Requirements

Define when specifications are required based on the item's lifecycle stage.

```yaml
specs:
  - name: NetsuiteItemId
    type: string
    required: "Production"
    validation:
      pattern: "^NS-\\d{8}$"
      description: "Must be NS- followed by 8 digits"

  - name: IONProcessTemplate
    type: string
    required: "Design"
    severity: "Warning"
    validation:
      pattern: "^TPL-[A-Z0-9]{8}$"
```

### Extend Existing Categories

Extend pre-defined categories with additional specifications or modifications.

```yaml
categories:
  - extends: duro/mechanical-categories/bearing@v1.0.0
    name: Custom Bearing # change the name for this category
    specs:
      - name: Surface Treatment
        type: string
        required: Production
        validation:
          enum: [Chrome, Nickel, Phosphate, None]
```

### Reference Common Specifications

Reuse common specifications across different categories using references.

```yaml
specs:
  - $ref: "#/commonSpecs/dimensions/standard_length"
    required: "*"
  - $ref: "#/commonSpecs/electrical/operating_temp_max"
    required: "Design"
```

### Import External Specifications

Use specifications defined in external libraries.

```yaml
specs:
  - $ref: "duro/electrical-categories/specs/voltage/input_voltage"
    required: Design
```

### Import Entire Specification Sets

Import all specifications from a set using the wildcard (\*) operator. This is useful when you need to include an entire group of related specifications.

```yaml
specs:
  # Imports all dimension specifications from Duro's mechanical specs
  - $ref: "duro/mechanical-categories/specs/dimensions/*"
    required: Prototype
    # This will include Height, Width, Length, Thickness, etc. as defined in the Duro specs
    # Each imported spec will maintain its validation rules while inheriting the required status
```

The wildcard import is particularly powerful when:

* You need all specifications from a logical grouping
* You want to maintain consistency with a standard specification set
* You want to reduce repetitive references to individual specifications

### Define Warning-Level Requirements

Specify requirements that trigger warnings rather than errors when not met.

```yaml
specs:
  - name: ERPCostCenter
    type: string
    required: "In Review"
    severity: "Warning"
    validation:
      pattern: "^CC-[A-Z]{2}-\\d{4}$"
```

### Create Integration Points

Define specifications for integration with external systems.

```yaml
specs:
  - name: JiraECO
    type: string
    validation:
      pattern: "^ECO-\\d{4}$"
      description: "Jira ECO number"

  - name: SAPMaterialNumber
    type: string
    validation:
      pattern: "^\\d{9}$"
      description: "Must be exactly 9 digits"
```

### Define Quality Control Requirements

Specify quality-related specifications and their validation rules.

```yaml
specs:
  - name: QualityInspectionFreq
    type: string
    required: "Production"
    validation:
      enum: [PerBatch, PerShift, Daily, Weekly, Monthly]
      description: "Frequency of quality inspections"

  - name: ISOCertification
    type: string
    required: "In Review"
    severity: "Warning"
    validation:
      pattern: "^ISO\\d{4,5}:\\d{4}$"
      description: "ISO certification number and year"
```

### Available Category Types

Define the type of category being created. The type affects validation rules and available specifications.

```yaml
categories:
  - code: "920"
    # Available types:
    type: MECHANICAL    # For mechanical parts and assemblies
    # OR
    type: ELECTRICAL    # For electrical components
    # OR
    type: ASSEMBLY      # For combined assemblies
    # OR
    type: DOCUMENT      # For documentation
    # OR
    type: SOFTWARE      # For software components
```

### Validation Types Reference

Define specifications using different validation types and formats.

#### String Validation

```yaml
commonSpecs:
  validation:
    # Pattern-based string validation
    - name: PartNumber
      type: string
      validation:
        pattern: "^[A-Z]{2}-\\d{6}$"
        description: "Must be 2 capital letters followed by 6 digits"

    # Enum-based string validation
    - name: Material
      type: string
      validation:
        enum: [Plastic, Metal, Glass, Ceramic]
        description: "Must be one of the allowed materials"

    # Unit-based measurements
    - name: Length
      type: string
      validation:
        pattern: "^\\d+(\\.\\d+)?\\s*(mm|cm|m|in)$"
        description: "Number with valid unit (e.g., 10mm, 2.5cm)"
```

#### Integer Validation

```yaml
commonSpecs:
  validation:
    # Range-based integer validation
    - name: Quantity
      type: integer
      validation:
        minimum: 1
        maximum: 1000
        description: "Must be between 1 and 1000"

    # Simple integer validation
    - name: PinCount
      type: integer
      validation:
        minimum: 1
        description: "Must be at least 1"
```

#### Paired Field Validation

```yaml
commonSpecs:
  dimensions:
    - name: Height
      type: paired
      components:
        value:
          name: "Height Value"
          type: integer
          validation:
            minimum: 0
            maximum: 999999
            description: "Numeric value for height"
        unit:
          name: "Height Unit"
          type: string
          validation:
            enum: ["mm", "cm", "m", "in"]
            description: "Unit for height measurement"
      display: "{value}{unit}"
```

#### Conditional Validation

```yaml
commonSpecs:
  conditional:
    # Example 1: Range-based conditional validation
    - name: ColorRange
      type: conditional
      components:
        color:
          name: "Color"
          type: string
          validation:
            enum: ["red", "blue", "green"]
            description: "Color selection"
        range:
          name: "Range Value"
          type: integer
          validation:
            description: "Range value"
            conditions:
              - when: "color == 'red'"
                minimum: 1
                maximum: 20
              - when: "color == 'blue'"
                minimum: 21
                maximum: 40
              - when: "color == 'green'"
                minimum: 41
                maximum: 50

    # Example 2: Enum-based conditional validation
    - name: Food Dye Color
      type: conditional
      display: "{baseColor} {number}"
      components:
        baseColor:
          name: "Base Color"
          type: string
          validation:
            enum: [Red, Yellow, Blue, Green]
            description: "Base color of the food dye"
        number:
          name: "Dye Number"
          type: integer
          validation:
            description: "FDA approved dye number for the selected color"
            conditions:
              - when: "baseColor == 'Red'"
                enum: [3, 40]
              - when: "baseColor == 'Yellow'"
                enum: [5, 6]
              - when: "baseColor == 'Blue'"
                enum: [1, 2]
              - when: "baseColor == 'Green'"
                enum: [3]
```

The conditional validation supports two types of validation rules:

1. **Range-based conditions**: Define minimum and maximum values based on another field's value
2. **Enum-based conditions**: Define specific allowed values based on another field's value

You can also specify a display format using the `display` property, which determines how the combined values should be presented. For example, `"{baseColor} {number}"` will show values like "Red 40" or "Yellow 5".

### Assign Attributes to Groups and Category Defaults

Attribute groups (`attributeGroups[].attributes`) and per-category default attributes (`categories[].defaults`) let you control which attributes appear on a component page and how they are grouped. When you save a category configuration, Duro validates these assignments before persisting them.

{% hint style="warning" %}
**System attributes cannot be placed in attribute groups or category defaults.** A system attribute referenced in a user-defined `attributeGroups[].attributes` list or in `categories[].defaults` is rejected, and the configuration save fails validation. System attributes are already rendered in a fixed location on the component page, so assigning them to a group or default would place the same attribute in two places — which is not allowed for any attribute.
{% endhint %}

{% hint style="info" %}
**Unresolved attribute references fail validation.** If an entry in `attributeGroups[].attributes` or `categories[].defaults` references an attribute that cannot be resolved, the save is rejected with a validation error rather than silently succeeding. Any previously saved defaults are left intact — an unresolved reference no longer clears existing defaults.
{% endhint %}

## Reading the Configuration via the API

Once your registry is processed, its attribute groups can be read back through the GraphQL API via the `CategoriesConfig` type.

{% hint style="info" %}
`CategoriesConfig.attributeGroups` always resolves to a list. When no attribute groups are defined it returns an empty list (`[]`), never `null`, so you can iterate over it directly without a null check.
{% endhint %}

## Version Format Reference

When referencing external categories or specifications, you can use different version formats:

```yaml
uses:
  # Full semantic version
  - duro/mechanical-categories@v1.0.0

  # Minor version only (equivalent to latest patch)
  - duro/electrical-categories@v1.0

  # Major version only (equivalent to latest minor.patch)
  - duro/software-categories@v1
```

**Note:** Using shorter versions will automatically use the latest compatible version within that scope.


# Revision Scheme

## Overview

The revision scheme configuration system allows you to define how component revisions are managed throughout their lifecycle. It enables you to:

* Define revision formats for different lifecycle stages
* Configure validation rules for revision transitions
* Specify allowed characters and patterns
* Set up automatic increment rules
* Establish consistent revision naming across your library

This configuration serves as the foundation for maintaining traceable and well-structured revision control in your product development process.

## Schema

The revision scheme is defined in a YAML file that specifies how revisions should be formatted and validated at each lifecycle stage.

### Basic Structure

```yaml
version: "1.0"
schema_type: "revision_scheme_config"

defaults:
  segments:
    integer:
      min_value: 1
      max_value: 999
    letter:
      min_value: "A"
      max_value: "ZZ"
  delimiter: "."
  empty_value: "-"
```

### Define Status Order

Establish the progression of lifecycle stages:

```yaml
status_order:
  - "Design"
  - "Prototype"
  - "Production"
  - "Obsolete"
```

### Configure Revision Schemes

Define revision formats for each status:

```yaml
schemes:
  - status: "Design"
    description: "Initial design phase revisions"
    segments:
      major:
        type: "letter"
        delimiter: ""
        required: true
      minor:
        type: "integer"
        min_value: 1
        max_value: 99
        required: false
    examples:
      - "A"
      - "A.1"
```

### Segment Types

#### Letter Segment

```yaml
major:
  type: "letter"
  delimiter: ""
  required: true
  min_value: "A"
  max_value: "Z"
```

#### Integer Segment

```yaml
minor:
  type: "integer"
  delimiter: "."
  required: false
  min_value: 1
  max_value: 99
```

#### Either Type Segment

```yaml
identifier:
  type: "either"  # Allows both letter and integer
  delimiter: ""
  required: true
```

### Validation Rules

Define rules for revision transitions and validation:

```yaml
validation:
  allowed_segment_types:
    - "integer"
    - "letter"
    - "either"

  required_fields:
    - "status"
    - "segments.major"

  transitions:
    allowed:
      - from: "Design"
        to: ["Prototype", "Obsolete"]
      - from: "Prototype"
        to: ["Production", "Obsolete"]
```

### Character Blacklist

Specify characters to exclude from revision schemes:

```yaml
blacklist:
  - "I"  # Avoid confusion with 1
  - "O"  # Avoid confusion with 0
  - "Q"  # Avoid confusion with 0
  - "S"  # Avoid confusion with 5
```

## Common Patterns

### Simple Letter-Based Revisions

```yaml
schemes:
  - status: "Design"
    segments:
      major:
        type: "letter"
        required: true
```

### Letter with Numeric Suffix

```yaml
schemes:
  - status: "Production"
    segments:
      major:
        type: "letter"
        required: true
      minor:
        type: "integer"
        delimiter: "."
        required: true
```

### Mixed Format for Obsolescence

```yaml
schemes:
  - status: "Obsolete"
    segments:
      major:
        type: "either"
        required: true
```

## Best Practices

1. **Revision Format**
   * Keep formats simple and intuitive
   * Use consistent delimiters
   * Consider future scaling needs
2. **Lifecycle Stages**
   * Define clear progression paths
   * Limit revision format changes between stages
   * Document transition rules
3. **Validation**
   * Blacklist confusing characters
   * Set appropriate value ranges
   * Define clear transition rules
4. **Documentation**
   * Include examples for each scheme
   * Document special cases
   * Maintain transition matrices

## Examples

### Basic Development Scheme

```yaml
schemes:
  - status: "Design"
    segments:
      major:
        type: "letter"
    examples:
      - "A"
      - "B"
      - "C"
```

### Production Scheme

```yaml
schemes:
  - status: "Production"
    segments:
      major:
        type: "letter"
      minor:
        type: "integer"
        delimiter: "."
    examples:
      - "A.1"
      - "B.2"
      - "C.10"
```

**Note:** Examples should reflect your actual use cases and common scenarios.


# CPN Schema Reference

## Overview

This document is a complete reference for the CPN (Component Part Number) Schema YAML specification. The schema defines the structure and constraints for CPN generation schemes in the Duro PLM system.

## Schema Information

* **Schema Version**: 1.0
* **JSON Schema**: `https://json-schema.org/draft-07/schema#`
* **Type**: Object

## Root Level Properties

| Property      | Type   | Required | Description                                                                                               |
| ------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------- |
| `version`     | string | Yes      | Version of the CPN schema. Must match pattern `^\d+\.\d+$`                                                |
| `schema_type` | string | Yes      | Must be `"cpn_generation_scheme"` (or `"cpn_generation_scheme_custom"` for custom template-group schemes) |
| `elements`    | array  | Yes      | Array of element definitions (declared first — this is the structural backbone)                           |
| `settings`    | object | Yes      | Global settings, including `element_mappings` that bind elements to system inputs                         |
| `examples`    | array  | Yes      | At least one example CPN that follows the scheme                                                          |

### Recommended Authoring Order

The default schemes shipped with Duro (and the order that is easiest for a human to read) declare keys in this order:

```yaml
version: '1.0'
schema_type: cpn_generation_scheme
elements:
  # ... structural definition of the CPN
settings:
  element_mappings:
    category: category
  allow_override: false
  allow_freeform: false
examples:
  - '410-00001'
```

You define the `elements` first because they describe *what* the CPN looks like. You then describe *how it behaves* under `settings`, including which element maps to the system's category input.

## Settings Object

The `settings` object contains global configuration for CPN generation.

```yaml
settings:
  element_mappings:            # Required when an element resolves from system data (e.g. categories)
    category: <element_name>
    variant: <element_name>    # Optional, only used when a variant element is present
  allow_override: boolean      # Default: false
  allow_freeform: boolean      # Default: false
  override_elements: [string]  # Default: null (all elements)
  freeform_validation:         # Optional, only used when allow_freeform is true
    pattern: string
    max_length: integer
    description: string
```

| Property              | Type    | Required | Default | Description                                                                                                                                                                    |
| --------------------- | ------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `element_mappings`    | object  | Cond.    | —       | Binds scheme element names to system inputs. Required when any element resolves from a system value (for example, a `list` element using `${{duro.categories}}`).              |
| `allow_override`      | boolean | No       | `false` | Whether manual CPN override is allowed. If `false`, the user must accept the system-generated CPN. If `true`, the user may enter another CPN (see `allow_freeform` for rules). |
| `allow_freeform`      | boolean | No       | `false` | Defines how overrides behave. If `false`, overrides must conform to the scheme. If `true`, overrides may be any unique valid string.                                           |
| `override_elements`   | array   | No       | `null`  | Element names that can be overridden individually. Only applies when `allow_override` is `true`. See "Element-Level Override Control" below.                                   |
| `freeform_validation` | object  | No       | —       | Custom validation for freeform overrides. Only applies when `allow_freeform` is `true`. Defaults to alphanumeric/hyphen/underscore with 50-character limit.                    |

### Element Mappings

`element_mappings` is the bridge between your scheme and the values Duro provides at generation time. When the generator receives a `categoryId` (or a variant identifier), it looks up which scheme element should receive that value.

```yaml
settings:
  element_mappings:
    category: category   # Bind the "category" input to the element named "category"
    variant: variant     # Optional — bind variant inputs to a variant element
```

| Key        | Required | Description                                                                                                                                                                 |
| ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `category` | Cond.    | The `name` of the `list` element that represents the part category. Required when the element draws values from `${{duro.categories}}` or any other category-driven source. |
| `variant`  | No       | The `name` of the element that represents the variant. Only set when your scheme has a variant element.                                                                     |

The key (`category` / `variant`) is the **system input**; the value is the **element `name`** declared in the `elements` array. They do not need to match — for example, if your category list element is named `prefix`, you would write `category: prefix`.

{% hint style="info" %}
Without `element_mappings.category`, a category-driven list element has no way to know which value to render and CPN generation will fail. This is the single most common reason a custom scheme rejects valid input.
{% endhint %}

### Freeform Validation Object

When `allow_freeform` is `true`, you can optionally specify custom validation rules:

| Property      | Type    | Required | Description                                                                                     |
| ------------- | ------- | -------- | ----------------------------------------------------------------------------------------------- |
| `pattern`     | string  | No       | Regex pattern that freeform CPNs must match. If not specified, uses default `^[a-zA-Z0-9\-_]+$` |
| `max_length`  | integer | No       | Maximum length for freeform CPNs. If not specified or ≤ 0, defaults to 50 characters            |
| `description` | string  | No       | Human-readable description of the format requirements shown to users                            |

#### Freeform Validation Examples

**Basic freeform with default validation:**

```yaml
settings:
  allow_override: true
  allow_freeform: true
  # Uses default pattern ^[a-zA-Z0-9\-_]+$ with 50 character limit
```

**Custom pattern for company naming convention:**

```yaml
settings:
  allow_override: true
  allow_freeform: true
  freeform_validation:
    pattern: "^[A-Z]{2,4}-\\d{4,6}$"
    max_length: 20
    description: "Format: 2-4 letters, hyphen, 4-6 digits"
```

### Element-Level Override Control

The `override_elements` setting controls which specific elements a user can override when `allow_override` is `true`.

| `override_elements`        | Behavior                                                       |
| -------------------------- | -------------------------------------------------------------- |
| `null` (default)           | All elements are overrideable when `allow_override` is `true`. |
| `["element1", "element2"]` | Only the listed elements can be overridden.                    |

```yaml
settings:
  allow_override: true
  allow_freeform: false
  override_elements: ["variant"]   # Only "variant" can be overridden
```

### Behavior Matrix

| `allow_override` | `allow_freeform` | `override_elements` | Behavior                                                                                                                |
| ---------------- | ---------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `false`          | `false`          | *ignored*           | User must accept the system-generated CPN. No manual input allowed.                                                     |
| `false`          | `true`           | *ignored*           | Same as above — `allow_freeform` has no effect when `allow_override` is `false`.                                        |
| `true`           | `false`          | `null` (default)    | User may override entire CPN or individual elements, but values must conform to the scheme.                             |
| `true`           | `false`          | `["element1"]`      | User may override only the listed elements. Values must conform to each element's validation rules.                     |
| `true`           | `true`           | `null` (default)    | User may override with freeform text or override individual elements. Freeform validates against `freeform_validation`. |
| `true`           | `true`           | `["element1"]`      | User may override with freeform text or override the listed elements.                                                   |

## Elements

The `elements` array contains definitions for each component of the CPN. Each element must be one of:

* `list` — selects a value from a predefined list or system reference
* `constant` — inserts a fixed value such as a delimiter or prefix
* `numeric_counter` — generates sequential numeric values within a range
* `hex_counter` — generates sequential hexadecimal values within a range
* `alpha_counter` — generates sequential uppercase alphabetic values within a range
* `free` — allows user-entered text validated by a regex and max length (only allowed inside a `group`)
* `group` — bundles multiple elements into a single logical unit

### Common Element Properties

All elements include these base properties:

| Property     | Type    | Required | Description                                                                                                |
| ------------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `type`       | string  | Yes      | Element type identifier.                                                                                   |
| `name`       | string  | Yes      | Unique name for the element. Used for cross-references such as `attachedTo` and `element_mappings`.        |
| `required`   | boolean | No       | Whether the element must appear in every generated CPN. Defaults to `false`.                               |
| `attachedTo` | array   | No       | Names of one or more elements this element is **scoped to**. Ensures uniqueness within the attached scope. |

#### About `attachedTo`

`attachedTo` is commonly used with counters and variants to ensure values are unique within the context of one or more parent elements. For example, a sequence counter attached to `category` will track an independent sequence per category. Multiple attachments are supported (e.g. `['prefix', 'sequence']`).

```yaml
- type: numeric_counter
  name: sequence
  required: true
  attachedTo: ['category']
  format:
    min_value: 1
    max_value: 99999
```

### List Element

A `list` element selects from a predefined set of values, an object array with metadata, or a system-provided template reference.

```yaml
- type: list
  name: string
  required: boolean
  use: string                 # Optional — pick a field when values are objects (e.g. "id")
  values: (string[] | object[] | template_string)
  validation:
    pattern: string           # Optional regex pattern
```

| Property             | Type            | Required | Description                                    |
| -------------------- | --------------- | -------- | ---------------------------------------------- |
| `values`             | array \| string | Yes      | Array of values or a template reference        |
| `use`                | string          | No       | When values are objects, which field to render |
| `validation.pattern` | string          | No       | Regex pattern to additionally validate values  |

Values must be one of:

1. Array of strings
2. Array of objects with `id`, `name`, and optional `description`
3. Template reference in the form `${{namespace.field}}`

#### List Element Examples

```yaml
# Pull values from the system's categories
- type: list
  name: category
  required: true
  values: ${{ duro.categories }}

# Inline string values with a validation pattern
- type: list
  name: prefix
  required: true
  values:
    - "410"
    - "591"
    - "423"
  validation:
    pattern: "^\\d{3}$"

# Object values with metadata, rendered by id
- type: list
  name: family
  required: true
  use: id
  values:
    - id: "410"
      name: Screws
      description: Mechanical fasteners
    - id: "591"
      name: Resistors
      description: Electronic components
```

{% hint style="info" %}
When a list element uses `${{duro.categories}}` (or any other category-driven source), you must declare `settings.element_mappings.category` so the generator can resolve the inbound `categoryId`.
{% endhint %}

### Constant Element

Constants are fixed strings injected into every generated CPN. Use them for delimiters (`-`, `.`) or static prefixes/suffixes.

```yaml
- type: constant
  name: string
  required: boolean
  value: string
```

| Property | Type   | Required | Description        |
| -------- | ------ | -------- | ------------------ |
| `value`  | string | Yes      | The constant value |

```yaml
- type: constant
  name: separator
  required: true
  value: "-"
```

### Numeric Counter

A `numeric_counter` generates sequential integers within a range. It is always **fixed length**, determined by the number of digits in `format.max_value`. Leading zeros are prepended automatically.

```yaml
- type: numeric_counter
  name: string
  required: boolean
  attachedTo: string[]
  format:
    min_value: integer       # Must be ≥ 0
    max_value: integer
```

```yaml
- type: numeric_counter
  name: sequence
  required: true
  attachedTo: ['category']
  format:
    min_value: 1
    max_value: 99999
```

The example above generates a 5-digit sequence (`00001` through `99999`), tracked independently per category:

* `410-00001`, `410-00002`, `410-00003`
* `591-00001` (independent sequence for category `591`)
* `410-00004` (continues category `410`)

### Hex Counter

A `hex_counter` generates sequential hexadecimal values within a range. Fixed length, zero-padded.

```yaml
- type: hex_counter
  name: string
  required: boolean
  attachedTo: string[]
  format:
    min_value: string        # Pattern: ^[0-9A-F]+$
    max_value: string        # Pattern: ^[0-9A-F]+$
```

```yaml
- type: hex_counter
  name: sequence
  required: true
  attachedTo: ['category']
  format:
    min_value: "0"
    max_value: "FF"
```

Generates `00`, `01`, … `FE`, `FF`.

### Alpha Counter

An `alpha_counter` generates sequential uppercase alphabetic values within a range. Like other counters, it is fixed length, determined by the length of `format.max_value`.

```yaml
- type: alpha_counter
  name: string
  required: boolean
  attachedTo: string[]
  format:
    min_value: string        # Pattern: ^[A-Z]+$
    max_value: string        # Pattern: ^[A-Z]+$
```

```yaml
- type: alpha_counter
  name: revision
  required: true
  attachedTo: ['base_cpn']
  format:
    min_value: "A"
    max_value: "Z"
```

Generates `A`, `B`, `C`, …, `Z`. With `min_value: "AA"` / `max_value: "ZZ"`, generates two-letter sequences (`AA`, `AB`, …, `ZZ`).

### Group Element

Groups bundle multiple elements into a single logical unit. They are useful for scoping (a variant attaches to the group rather than to each member element) and for organizing complex schemes.

```yaml
- type: group
  name: string
  required: boolean
  reusable: boolean          # Optional, default false
  attachedTo: string[]       # Optional
  elements: array
```

| Property     | Type    | Required | Description                                                                                                                                       |
| ------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `elements`   | array   | Yes      | Array of nested element definitions (at least one).                                                                                               |
| `reusable`   | boolean | No       | When `true`, group instances are tracked and reused. The inner counter's `attachedTo` defines what makes an instance unique. Defaults to `false`. |
| `attachedTo` | array   | No       | Names of elements this group is scoped to.                                                                                                        |

Group elements can contain: `list`, `constant`, `free`, `numeric_counter`, `hex_counter`, and `alpha_counter`. The `free` element type is only valid inside a group.

#### Group Example — Optional Variant Suffix

```yaml
- type: group
  name: variant_group
  required: false
  elements:
    - type: constant
      name: separator
      value: "."
    - type: free
      name: variant
      validation:
        pattern: "^\\w{1,10}$"
```

Produces CPNs such as `123-4567.A` or `123-4567.TEST1`.

#### Group Example — Base CPN with Variant

```yaml
- type: group
  name: base_cpn
  required: true
  elements:
    - type: list
      name: prefix
      required: true
      values:
        - id: "Category100"
          name: "100"
      validation:
        pattern: "^[1-9][0-9]{2}$"
    - type: constant
      name: delimiter_1
      value: "-"
    - type: numeric_counter
      name: sequence
      required: true
      attachedTo: ['prefix']
      format:
        min_value: 1
        max_value: 99999

- type: constant
  name: delimiter_2
  value: "-"

- type: list
  name: variant
  required: true
  attachedTo: ['base_cpn']
  values:
    - id: "A"
      name: "A"
    - id: "B"
      name: "B"
  validation:
    pattern: "^[A-Z]{1}$"
```

`variant` is attached to the `base_cpn` group, so variant values are scoped to the complete base number rather than to `prefix` and `sequence` separately.

### Free Text Element

The `free` element accepts user-entered text. It is only valid **inside a group**, and `allow_override` must be `true` for the user to be able to enter a value.

```yaml
- type: free
  name: string
  validation:
    pattern: string
    max_length: integer
```

| Property                | Type    | Required | Description                  |
| ----------------------- | ------- | -------- | ---------------------------- |
| `validation.pattern`    | string  | Yes      | Regex pattern for validation |
| `validation.max_length` | integer | Yes      | Maximum length of the text   |

## Examples Array

The `examples` array must contain at least one example CPN that follows the defined scheme.

```yaml
examples:
  - "410-00001"
  - "591-00042"
```

## Template References

Template references pull dynamic value lists from the system:

```yaml
values: "${{ namespace.field }}"
```

Template references must match the pattern `^\$\{\{\s*[\w\.]+\s*\}\}$`. Currently the only supported namespace is `duro`, and the only supported fields are `categories` and `families`. When a list element uses a category template reference, remember to set `settings.element_mappings.category`.

## Complete Schema Examples

### Example 1: Basic Semi-Intelligent Scheme

This is the default semi-intelligent scheme shipped with Duro. Categories come from the system; the sequence is tracked per category.

```yaml
version: '1.0'
schema_type: cpn_generation_scheme
elements:
  - name: category
    type: list
    values: ${{ duro.categories }}
    required: true
  - name: category_counter_separator
    type: constant
    required: true
    value: '-'
  - name: sequence
    type: numeric_counter
    required: true
    format:
      min_value: 1
      max_value: 99999
    attachedTo:
      - category
settings:
  element_mappings:
    category: category
  allow_freeform: false
  allow_override: false
examples:
  - '410-00001'
  - '591-00042'
  - '423-01234'
```

### Example 2: Non-Intelligent (Pure Sequence)

A flat numeric scheme with no categories and therefore no `element_mappings`.

```yaml
version: '1.0'
schema_type: cpn_generation_scheme
elements:
  - name: sequence
    type: numeric_counter
    required: true
    format:
      min_value: 100001
      max_value: 999999
settings:
  allow_freeform: false
  allow_override: false
examples:
  - '100001'
  - '100042'
  - '101234'
```

### Example 3: Category, Sequence, and Variant

A scheme with an overrideable variant element. Because the variant identifier is a system input, `element_mappings.variant` is declared.

```yaml
version: '1.0'
schema_type: cpn_generation_scheme

elements:
  - type: list
    name: category
    required: true
    use: id
    values:
      - id: "410"
        name: Screws
        description: Mechanical fasteners
    validation:
      pattern: "^\\d{3}$"

  - type: constant
    name: separator
    required: true
    value: "-"

  - type: numeric_counter
    name: sequence
    required: true
    attachedTo: [category]
    format:
      min_value: 1
      max_value: 9999

  - type: list
    name: variant
    required: true
    attachedTo: [category, sequence]
    values: ["A", "B", "C"]

settings:
  element_mappings:
    category: category
    variant: variant
  allow_override: true
  allow_freeform: true
  override_elements: ["variant"]
  freeform_validation:
    pattern: "^[A-Z]{2,4}-\\d{4,6}$"
    max_length: 20
    description: "Format: 2-4 letters, hyphen, 4-6 digits"

examples:
  - "410-0001-A"
  - "410-0001-B"
```

## Validation Rules

1. `version` must match `^\d+\.\d+$`.
2. `schema_type` must be `"cpn_generation_scheme"` (or `"cpn_generation_scheme_custom"` for custom template-group schemes).
3. All element `name` values must be unique within their scope.
4. Counter ranges must be valid (`min_value` ≤ `max_value`, and `min_value` ≥ 0 for numeric counters).
5. Hex counter values must match `^[0-9A-F]+$`. Alpha counter values must match `^[A-Z]+$`.
6. Template references must match `^\$\{\{\s*[\w\.]+\s*\}\}$`.
7. At least one example CPN must be provided.
8. Group elements must contain at least one element.
9. Groups can be optional even if their inner elements are required.
10. `element_mappings.category` is required whenever an element resolves from a category-driven source (e.g. `${{duro.categories}}`).

## Best Practices

* **Structure**
  * Declare `elements` before `settings` so the structural shape of the CPN reads top-down.
  * Place required elements before optional ones.
  * Group related elements together with `group`.
* **Mappings**
  * Always declare `settings.element_mappings.category` for category-driven schemes. Without it, generation will fail at runtime even though the scheme is otherwise valid.
* **Validation**
  * Include regex patterns on `list` values where format matters.
  * Size counter ranges generously for future growth.
* **Documentation**
  * Include descriptions on list values when the `id` is opaque.
  * Provide diverse examples covering edge cases.


# Change Order Workflows

## Overview

Change Order Workflow Templates provide a powerful way to customize the approval process for change orders in your PLM library. Using YAML configuration files, you can define multi-stage review processes, specify required approvers, and configure automated actions that align with your organization's change management procedures.

## Why Use Workflow Templates?

Every organization has unique requirements for managing engineering changes. Workflow templates allow you to:

* **Standardize Processes**: Ensure consistent review procedures across all change orders
* **Enforce Compliance**: Meet regulatory requirements with mandatory approval stages
* **Automate Actions**: Configure automatic notifications and resolution behaviors
* **Customize Fields**: Capture the specific information your team needs for decision-making

## Getting Started

Each Duro library comes with two default workflow templates:

1. **Default Template** ([`default.yaml`](https://phoenix-production.durohub.com/static/schemes/change-orders/default.yaml)): A single-stage approval workflow with a **Details** group containing a single *Reason for Change* field.
2. **Double Template** ([`double.yaml`](https://phoenix-production.durohub.com/static/schemes/change-orders/double.yaml)): A two-stage workflow with an **Impact Analysis** group capturing cost and description impact.

These templates serve as starting points that you can customize for your specific needs.

{% hint style="info" %}
Both default templates ship at schema `version: '1.1'` and are **ECO-only** — they declare `co_types: [ECO]` and `default_co_type: ECO`. Change orders created from them are always `ECO` unless you first add other [change order types](/core-concepts/change-orders#change-order-types) to `co_types`. See [Declaring Allowed Change Order Types](#declaring-allowed-change-order-types).

Change order type is a built-in concern configured through `co_types` / `default_co_type` — **not** a content field. Neither built-in template defines a legacy `change_type` enum in `details.info.groups`; that field has been removed from the shipped v1.1 templates.
{% endhint %}

### Basic Workflow Structure

Every workflow template follows this structure:

```yaml
version: '1.0'
description: A clear description of this workflow's purpose
schema_type: change_order_scheme

details:
  info:
    groups:
      # Custom fields for change order creation

stages:
  open:
    # Review stages configuration
  resolved:
    # Actions when change order is resolved
  closed:
    # Final state configuration
```

## Creating Custom Fields

The `details.info.groups` section lets you define custom fields that users fill out when creating a change order. Fields are organized into logical groups for better user experience.

### Example: Adding Change Classification

```yaml
details:
  info:
    groups:
      - name: Change Classification
        icon: mdi-tag-multiple
        fields:
          - name: change_category
            type: enum
            label: Category
            description: Primary category of this change
            options:
              - label: Design Update
                value: design
              - label: Cost Reduction
                value: cost
              - label: Quality Improvement
                value: quality
            validations:
              required: true
```

### Supported Field Types

* **text**: Single-line text input
* **longtext**: Multi-line text area
* **number**: Numeric values
* **date**: Date picker
* **currency**: Monetary values with currency formatting
* **enum**: Single selection from predefined options
* **list**: Multiple selections from predefined options

## Declaring Allowed Change Order Types

A template can declare which [change order types](/core-concepts/change-orders#change-order-types) users may choose from when creating a change order, and which one is applied by default. This requires schema `version: '1.1'`.

```yaml
version: '1.1'
description: Engineering change workflow supporting orders, requests, and doc updates
schema_type: change_order_scheme

co_types: [ECO, ECR, DCO]
default_co_type: ECO

details:
  info:
    groups: []

stages:
  open:
    - name: Engineering Review
      types: ['Majority']
      default: Majority
```

* `co_types` — the non-empty list of types this template allows. When a change order is created from the template, its `coType` must be one of these values, or creation fails with [`CO_TYPE_NOT_ALLOWED`](/advanced-topics/error-handling#co-type-not-allowed).
* `default_co_type` — the type applied when the creator does not pass an explicit `coType`. It must be a member of `co_types`; if omitted, it defaults to `ECO`.

{% hint style="warning" %}
`DCO` (Documentation Change Order) is more than a label — a DCO never bumps component revisions and cannot change a component's status or revision. Duro enforces this with a built-in [DCO baseline validation](/library-configuration/change-order-validations#dco-baseline-freeze) that freezes each DCO item against its last-released baseline and blocks the change order — at submit and again at close — if the item's live status or revision has drifted. Only include `DCO` in `co_types` for workflows meant for documentation-only, no-revision-bump changes; any item that also needs a status or revision change must be routed through a non-`DCO` type instead. See [Change Orders → Change Order Types](/core-concepts/change-orders#change-order-types).
{% endhint %}

Templates without `co_types` (any `version: '1.0'` template) implicitly allow only `ECO`. For the full list of type values and all validation rules, see the [Workflow YAML Reference](/library-configuration/change-order-workflow-reference#change-order-types).

{% hint style="warning" %}
**Don't model change order type as a content field.** Older templates declared a `change_type` enum (ECO / MCO / DCO) inside `details.info.groups` to capture the type. That field is redundant now that type is first-class via `co_types` / `default_co_type`, and it has been removed from the built-in v1.1 templates. Use `co_types` / `default_co_type` to control the type instead of adding a custom enum field.

Existing `version: '1.0'` templates (and change orders already created from them) that still define a `change_type` content field keep working and continue to show it — the removal only affects the shipped v1.1 defaults.
{% endhint %}

## Configuring Approval Stages

The `stages.open` section defines your review process. You can create multiple stages that execute sequentially.

### Single-Stage Approval

```yaml
stages:
  open:
    - name: Engineering Review
      types: ['Unanimous', 'Majority', 'Minimum']
      default: Majority
      minReviewers: 2
```

### Multi-Stage Approval

```yaml
stages:
  open:
    - name: Technical Review
      types: ['Unanimous']
      default: Unanimous
      minReviewers: 2
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174001
          - 123e4567-e89b-12d3-a456-426614174002

    - name: Management Approval
      types: ['Majority', 'Minimum']
      default: Majority
      minReviewers: 1
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174003
```

### Pre-Assigning Notifiers

You can also pre-assign notifiers to open stages using a `notifyList` block. Notifiers are added to the stage's notify list when the change order is created but do not participate in the approval process.

A `notifyList` accepts two arrays:

* `users` — internal users, specified as UUID strings (same format as `reviewers.users`).
* `emails` — external recipients, specified as plain email addresses. Use this for people who do not have a Duro account.

```yaml
stages:
  open:
    - name: Engineering Review
      types: ['Unanimous']
      default: Unanimous
      minReviewers: 2
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174001
      notifyList:
        users:
          - 123e4567-e89b-12d3-a456-426614174004
        emails:
          - supplier@example.com
```

{% hint style="info" %}
`reviewers.users` and `notifyList.users` are **internal-only**: each entry must be the UUID of a user in the library's organization. External addresses belong in `notifyList.emails`. To look up a user's UUID, query your organization's members — see [RBAC → Get Organization Members with Roles](/advanced-topics/rbac#get-organization-members-with-roles).

Uploading a template via the GraphQL API requires UUIDs in these arrays. The web template editor also lets you type email addresses for internal users and resolves them to UUIDs on save, so you only need to look up IDs when authoring YAML by hand.
{% endhint %}

#### Editor vs. API: participant values

The structure is identical for both authoring paths — only the participant values differ:

```yaml
# Web template editor — type email addresses; resolved to org users on save
reviewers:
  users:
    - jane@acme.com
```

```yaml
# GraphQL API / direct YAML upload — user UUIDs required
reviewers:
  users:
    - 123e4567-e89b-12d3-a456-426614174001
```

In the editor, unrecognized addresses in a `notifyList` are kept as external `notifyList.emails` recipients. The examples throughout this guide and the [reference](/library-configuration/change-order-workflow-reference) use UUIDs, since they're written for direct API upload.

### Approval Types Explained

* **Unanimous**: All reviewers must approve
* **Majority**: More than 50% of reviewers must approve
* **Minimum**: At least the minimum number of reviewers must approve

## Adding Validations

Field validations ensure data quality and completeness:

```yaml
fields:
  - name: estimated_cost
    type: currency
    label: Estimated Cost
    validations:
      required: true
      min: 0
      max: 1000000

  - name: part_number
    type: text
    label: Affected Part Number
    validations:
      pattern: "^[A-Z]{3}-\\d{4}$"
      required: true
```

## Automating Resolutions

Configure automatic actions when change orders are approved, rejected, or withdrawn:

```yaml
resolved:
  resolutions:
    onapproval: [AUTO_CLOSE]      # Automatically close when approved
    onrejection: [MANUAL_CLOSE]   # Require manual closure when rejected
    onwithdrawal: [MANUAL_CLOSE]  # Require manual closure when withdrawn
```

## Best Practices

### 1. Start Simple

Begin with a basic workflow and add complexity as needed. It's easier to expand a working workflow than debug a complex one.

### 2. Use Descriptive Names

Choose clear, meaningful names for stages and fields:

* ✅ Good: `manufacturing_impact_assessment`
* ❌ Avoid: `field1`, `stage_a`

### 3. Group Related Fields

Organize fields into logical groups to improve the user experience:

```yaml
groups:
  - name: Impact Analysis
    icon: mdi-chart-line
    fields:
      - name: schedule_impact
      - name: cost_impact
      - name: quality_impact
```

### 4. Document Your Workflow

Always include a clear description:

```yaml
description: |
  Engineering change workflow for hardware modifications.
  Requires technical review followed by management approval
  for changes exceeding $10,000 in impact.
```

### 5. Test Thoroughly

Before deploying a new workflow:

1. Create test change orders using the workflow
2. Verify all stages execute correctly
3. Confirm notifications reach the right people
4. Test edge cases (rejections, withdrawals)

## Common Patterns

### Pattern 1: Cost-Based Escalation

For organizations where higher-cost changes need additional approval:

```yaml
stages:
  open:
    - name: Initial Review
      types: ['Majority']
      default: Majority
      minReviewers: 2

    - name: Executive Approval
      types: ['Minimum']
      default: Minimum
      minReviewers: 1
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174003
```

### Pattern 2: Department-Specific Reviews

When different departments need to review changes:

```yaml
stages:
  open:
    - name: Engineering Review
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174001
          - 123e4567-e89b-12d3-a456-426614174002

    - name: Manufacturing Review
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174005
          - 123e4567-e89b-12d3-a456-426614174006

    - name: Quality Review
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174007
          - 123e4567-e89b-12d3-a456-426614174008
```

## Creating Templates via the GraphQL API

You can create and manage change order templates programmatically using the GraphQL API through Apollo Studio, Duro's interactive API explorer. This approach is useful for:

* Testing and validating your workflow configurations before deployment
* Creating templates across multiple libraries
* Version controlling your workflow configurations

### Prerequisites

Before you begin, you'll need:

1. **A Duro API Key**: Navigate to `https://durohub.com/org/@<your-org>/libs/<your-library>/settings/api-keys` to generate one. See [Getting Started → Authentication](/getting-started/authentication) for more information.
2. **Your YAML workflow template**: Either use one of the default templates or create your own custom configuration

### Step 1: Prepare Your YAML Configuration

First, create your workflow template YAML file. Here's a starter template:

```yaml
version: '1.0'
description: Engineering Change Review Process
schema_type: change_order_scheme

details:
  info:
    groups:
      - name: Change Information
        icon: mdi-information
        fields:
          - name: change_description
            type: longtext
            label: Change Description
            description: Detailed description of the proposed change
            validations:
              required: true

          - name: impact_assessment
            type: enum
            label: Impact Level
            description: Estimated impact of this change
            options:
              - label: Low Impact
                value: low
              - label: Medium Impact
                value: medium
              - label: High Impact
                value: high
            validations:
              required: true

stages:
  open:
    - name: Engineering Review
      types: ['Unanimous', 'Majority']
      default: Majority
      minReviewers: 2
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174001

resolved:
  resolutions:
    onapproval: [AUTO_CLOSE]
    onrejection: [MANUAL_CLOSE]
    onwithdrawal: [MANUAL_CLOSE]

closed: {}
```

### Step 2: Minify Your YAML

Apollo Studio requires the YAML to be provided as a single-line string. To convert your YAML:

1. **Visit the YAML Minifier**: Go to <https://onlineyamltools.com/minify-yaml>
2. **Paste your YAML**: Copy your YAML configuration and paste it into the left input box
3. **Copy the minified output**: The right side will show your minified YAML as a single line. Copy this entire string.

![YAML Minifier showing conversion from formatted to minified YAML](/files/sXdf7oXfKJklwAVy7mmo)

### Step 3: Open Apollo Studio

1. **Navigate to Apollo Studio**: Open <https://api.durohub.com/graphql> in your browser
2. **Set up headers**: Click on the "Headers" tab at the bottom of the Operation panel and add the following required headers:

| Header           | Value                    | Description                                    |
| ---------------- | ------------------------ | ---------------------------------------------- |
| `x-api-key`      | `your-api-key`           | Your API authentication token                  |
| `x-organization` | `@your-org`              | Your organization slug                         |
| `x-library`      | `@your-org/your-library` | The library where the template will be created |

![Apollo Studio Headers tab showing x-api-key configuration](/files/NVD9dvIUQq40DkQLZQSu)

### Step 4: Create Your Template

1. **Paste the mutation**: In the Operation panel, paste the following GraphQL mutation:

```graphql
mutation CreateTemplate($input: CreateTemplateInput!) {
  changeOrders {
    createTemplate(input: $input) {
      id
      name
      config
      library {
        id
        name
      }
      createdAt
    }
  }
}
```

2. **Set up variables**: In the Variables panel, create your input object:

```json
{
  "input": {
    "name": "My Engineering Review Process",
    "configYAML": "YOUR_MINIFIED_YAML_HERE"
  }
}
```

Replace `YOUR_MINIFIED_YAML_HERE` with the minified YAML string you copied from Step 2.

3. **Execute the mutation**: Click the "CreateTemplate" button to run the mutation

![Apollo Studio showing the complete setup for creating a change order template](/files/fkE2K4iDuhtKlPj4shDP)

### Step 5: Verify Your Template

A successful response will show:

```json
{
  "data": {
    "changeOrders": {
      "createTemplate": {
        "id": "123e4567-e89b-12d3-a456-426614174001",
        "name": "My Engineering Review Process",
        "config": {
          "version": "1.0",
          "description": "Engineering Change Review Process",
          // ... rest of your configuration
        },
        "library": {
          "id": "fc28b204-0cd0-46b3-96eb-b0720c16c423",
          "name": "Main Library"
        },
        "createdAt": "2024-01-15T10:30:00Z"
      }
    }
  }
}
```

Your template is now created and ready to use in your change order workflows!

### Troubleshooting Common Issues

#### YAML Formatting Errors

If you receive a YAML parsing error:

* Ensure your YAML is valid before minifying (use a YAML validator)
* Check that the minified string is properly copied without line breaks
* Verify all quotes and special characters are properly escaped

#### Authentication Failed

If you get an authentication error:

* Verify your API key is correct
* Ensure the `x-api-key` header is properly set in the Headers tab
* Confirm your API key has the necessary permissions

#### Invalid Configuration

If the template configuration is rejected:

* Review the error message for specific validation issues
* Ensure all required fields are present in your YAML
* Verify user IDs are valid UUIDs belonging to your organization

#### Participant Validation Errors

When a template includes `reviewers.users` or `notifyList.users`, Duro validates the user IDs at upload time:

| Error Code                                | Cause                                                                          |
| ----------------------------------------- | ------------------------------------------------------------------------------ |
| `TEMPLATE_PARTICIPANT_INVALID_USER_ID`    | User ID is not a valid UUID or does not exist                                  |
| `TEMPLATE_PARTICIPANT_WRONG_ORGANIZATION` | User exists but is not a member of the library's organization                  |
| `TEMPLATE_PARTICIPANT_MALFORMED_CONFIG`   | Structural error (e.g., `reviewers` is not an object, `users` is not an array) |

External `notifyList.emails` entries are not validated against organization membership — any well-formed email address is accepted.

## Participant Population Behavior

When a change order is created from a template that includes pre-assigned reviewers or a notify list, Duro automatically populates the corresponding stages — adding `reviewers.users` as stage reviewers, and `notifyList.users` / `notifyList.emails` to the stage's notify list.

### Validation at Upload vs. Creation

Participant validation happens at two points with different behavior:

* **At template upload** (strict): All user IDs must be valid UUIDs, exist in the system, and belong to the library's organization. The template is rejected if any user fails validation.
* **At change order creation** (lenient): Valid users are added to their stages. Invalid or missing users are skipped, and warnings are logged to the change order's activity feed.

This two-phase approach ensures templates stay clean while allowing change orders to be created even if a user has been removed from the organization since the template was last updated.

### Activity Feed Logging

When participants are skipped during change order creation, Duro logs a `template.participants.skipped` activity entry containing the skipped user IDs, their intended stages, and the reason (not found or wrong organization).

## Next Steps

1. Review the [Change Order Workflow Reference](/library-configuration/change-order-workflow-reference) for complete specification details
2. Explore example templates in your library's configuration
3. Start with a default template and customize it for your needs
4. Test your workflow with a pilot group before full deployment


# Change Order Workflow Reference

## Overview

This document provides a complete reference for the Change Order Workflow Template YAML specification. The schema defines the structure and constraints for change order approval workflows in the Duro PLM system.

## Schema Information

* **Schema Version**: 1.0 (base) / 1.1 (adds change order types — see [Change Order Types](#change-order-types))
* **Schema URL**: [`https://phoenix-production.durohub.com/static/schemes/change-orders/schema.json`](https://phoenix-production.durohub.com/static/schemes/change-orders/schema.json)
* **Type**: Object

### JSON Schema Validation

The JSON Schema provides automated validation for your workflow templates. When editing YAML files, you can use this schema to:

* **Validate your templates** before deployment to catch errors early
* **Get auto-completion** in editors that support YAML Language Server
* **Ensure compliance** with all required fields and constraints

To use the schema in your YAML files, add this comment at the top:

```yaml
# yaml-language-server: $schema=https://phoenix-production.durohub.com/static/schemes/change-orders/schema.json
```

Many editors (VS Code, IntelliJ, etc.) will then provide real-time validation and helpful suggestions as you write your workflow templates.

## Root Level Properties

| Property          | Type   | Required  | Description                                                                                                                                         |
| ----------------- | ------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`         | string | Yes       | Schema version, quoted (e.g., `"1.0"` or `"1.1"`). Must be a string — an unquoted numeric version is rejected.                                      |
| `description`     | string | Yes       | Human-readable description (max 500 chars)                                                                                                          |
| `schema_type`     | string | Yes       | Must be `"change_order_scheme"`                                                                                                                     |
| `details`         | object | Yes       | Custom field definitions                                                                                                                            |
| `validations`     | array  | No        | Global validation rules                                                                                                                             |
| `stages`          | object | Yes       | Workflow stage configuration                                                                                                                        |
| `co_types`        | array  | v1.1 only | Change order types this template allows. Required when `version` is `"1.1"`. See [Change Order Types](#change-order-types).                         |
| `default_co_type` | string | No        | The type applied when a change order is created from this template without an explicit `coType`. Defaults to `ECO`; must be a member of `co_types`. |

### Example Root Structure

```yaml
version: "1.0"
description: "Engineering change order workflow with dual approval"
schema_type: "change_order_scheme"
details: { }
validations: [ ]
stages: { }
```

## Change Order Types

The `co_types` and `default_co_type` properties (schema version `1.1`) add two root-level fields that declare which [change order types](/core-concepts/change-orders#change-order-types) a template allows and which one it applies by default.

```yaml
version: "1.1"
description: "Engineering change workflow supporting ECO, ECR, and documentation updates"
schema_type: "change_order_scheme"

co_types: [ECO, ECR, DCO]
default_co_type: ECO

details: { }
stages:
  open: [ ]
```

| Property          | Type              | Required   | Description                                                                                                                  |
| ----------------- | ----------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `co_types`        | array of `CoType` | Yes (v1.1) | The types a user may choose from when creating a change order from this template. Must be non-empty. Duplicates are removed. |
| `default_co_type` | `CoType`          | No         | The type used when the creator does not pass an explicit `coType`. Defaults to `ECO`. Must be one of `co_types`.             |

### `CoType` values

| Value | Meaning                                                           |
| ----- | ----------------------------------------------------------------- |
| `ECO` | Engineering Change Order                                          |
| `MCO` | Manufacturing Change Order                                        |
| `DCO` | Documentation Change Order — **revision-frozen** (see note below) |
| `ECR` | Engineering Change Request                                        |
| `MCR` | Manufacturing Change Request                                      |
| `DCR` | Documentation Change Request                                      |
| `ECN` | Engineering Change Notice                                         |
| `MCN` | Manufacturing Change Notice                                       |
| `DCN` | Documentation Change Notice                                       |

{% hint style="info" %}
Only `DCO` alters behavior: change orders of type `DCO` never bump component revisions and cannot change a component's status or revision. A built-in [DCO baseline validation](/library-configuration/change-order-validations#dco-baseline-freeze) enforces this by freezing each DCO item against its last-released baseline — the change order is blocked at submit, and its release is aborted at close, if the item's live status or revision has drifted or the item has no releasable baseline. All other types are classification metadata. See [Change Orders → Change Order Types](/core-concepts/change-orders#change-order-types).
{% endhint %}

### Rules and validation

* `co_types` and `default_co_type` are only recognized when `version` is `"1.1"`. Including them under `"1.0"` (or an unset version) is rejected (`CO_TYPES_UNSUPPORTED_IN_SCHEMA_VERSION`).
* When `version` is `"1.1"`, `co_types` is **required** and must contain at least one value (`CO_TYPES_REQUIRED_IN_SCHEMA_VERSION` / `CO_TYPES_EMPTY`).
* `default_co_type` must be a member of `co_types` (`DEFAULT_CO_TYPE_NOT_ALLOWED`). If omitted, it defaults to `ECO` — so a template that omits `default_co_type` must include `ECO` in `co_types`.
* Every entry in `co_types` and `default_co_type` must be a valid `CoType` value (`INVALID_CO_TYPE`).
* `version` must be authored as a **quoted string**. An unquoted `version: 1.1` parses as a number and is rejected (`SCHEMA_VERSION_MUST_BE_STRING`).

These codes are raised at **template upload**. The related **create-time** error — raised when a change order is created with a `coType` outside a template's `co_types` — is [`CO_TYPE_NOT_ALLOWED`](/advanced-topics/error-handling#co-type-not-allowed).

### Legacy (`1.0`) templates

A template without `co_types` (any `1.0` template) is treated as allowing only `ECO`, with `ECO` as the default. When queried through the GraphQL API, such a template returns `null` for both `coTypes` and `defaultCoType` — do not round-trip those nulls back into a `1.0` config on update. To offer additional types, migrate the template to `version: "1.1"` and add a `co_types` array.

{% hint style="info" %}
The built-in default templates shipped with every library ([`default.yaml`](https://phoenix-production.durohub.com/static/schemes/change-orders/default.yaml) and [`double.yaml`](https://phoenix-production.durohub.com/static/schemes/change-orders/double.yaml)) are authored at `version: "1.1"` with `co_types: [ECO]` and `default_co_type: ECO`. They are ECO-only out of the box; add other `CoType` values to `co_types` to allow them.

These templates do **not** define a `change_type` content field in `details.info.groups`. Change order type is set entirely through `co_types` / `default_co_type`; the legacy `change_type` enum that earlier templates used for this purpose has been removed. `default.yaml` now ships a *Reason for Change* field, and `double.yaml` an *Impact Analysis* group. Content fields named `change_type` remain valid as ordinary custom fields for backward compatibility, but are no longer part of the built-in defaults.
{% endhint %}

## Details Object

The `details` object contains custom field definitions organized in groups.

### Structure

```yaml
details:
  info:
    groups:
      - name: string
        icon: string (optional)
        description: string (optional)
        fields: [ ]
```

### Group Properties

| Property      | Type   | Required | Description                                    |
| ------------- | ------ | -------- | ---------------------------------------------- |
| `name`        | string | Yes      | Display name (max 100 chars)                   |
| `icon`        | string | No       | Material Design Icon (format: `mdi-icon-name`) |
| `description` | string | No       | Group description (max 500 chars)              |
| `fields`      | array  | Yes      | Array of field definitions                     |

## Field Definitions

Each field in a group has the following properties:

| Property      | Type    | Required    | Description                                             |
| ------------- | ------- | ----------- | ------------------------------------------------------- |
| `type`        | string  | Yes         | Field type (see Field Types section)                    |
| `name`        | string  | No          | Programmatic name (pattern: `^[a-zA-Z_][a-zA-Z0-9_]*$`) |
| `label`       | string  | Yes         | Display label (max 100 chars)                           |
| `description` | string  | No          | Help text (max 500 chars)                               |
| `placeholder` | string  | No          | Placeholder text (max 200 chars)                        |
| `required`    | boolean | No          | Deprecated - use `validations.required`                 |
| `validations` | object  | No          | Field validation rules                                  |
| `default`     | string  | No          | Default value (for enum fields)                         |
| `options`     | array   | Conditional | Required for list/enum types                            |
| `multiSelect` | boolean | No          | Enable multi-selection (list type only)                 |

### Field Types

| Type       | Description               | Additional Properties                |
| ---------- | ------------------------- | ------------------------------------ |
| `text`     | Single-line text input    | -                                    |
| `longtext` | Multi-line text area      | -                                    |
| `number`   | Numeric input             | `validations.min`, `validations.max` |
| `date`     | Date picker               | -                                    |
| `currency` | Currency amount           | `validations.min`, `validations.max` |
| `enum`     | Single selection dropdown | `options`, `default`                 |
| `list`     | Multiple selection        | `options`, `multiSelect`             |

### Field Validation Object

```yaml
validations:
  required: boolean
  min: integer
  max: integer
  pattern: string (regex)
```

| Property   | Type    | Applies To             | Description              |
| ---------- | ------- | ---------------------- | ------------------------ |
| `required` | boolean | All types              | Field is mandatory       |
| `min`      | integer | number, currency, text | Minimum value or length  |
| `max`      | integer | number, currency, text | Maximum value or length  |
| `pattern`  | string  | text                   | Regex validation pattern |

### Options for List/Enum Fields

```yaml
options:
  - label: string
    value: string
    description: string (optional)
```

| Property      | Type   | Required | Description                      |
| ------------- | ------ | -------- | -------------------------------- |
| `label`       | string | Yes      | Display text (max 100 chars)     |
| `value`       | string | Yes      | Stored value (max 100 chars)     |
| `description` | string | No       | Option help text (max 500 chars) |

## Validations Array

Global validation rules (currently reserved for future use).

```yaml
validations:
  - id: string
    severity: string
```

| Property   | Type   | Required | Description                           |
| ---------- | ------ | -------- | ------------------------------------- |
| `id`       | string | Yes      | Validation ID (pattern: `^\d+\.\d+$`) |
| `severity` | string | Yes      | One of: `error`, `warn`, `info`       |

## Stages Object

Defines the workflow stages and their behavior.

```yaml
stages:
  open: [ ]      # Required: Active review stages
  resolved: { }  # Optional: Resolution configuration
  closed: { }    # Optional: Closed state configuration
  onHold: { }    # Optional: On-hold state configuration
```

### Open Stages Array

Array of sequential review stages. Each stage has:

| Property       | Type    | Required | Description                                                          |
| -------------- | ------- | -------- | -------------------------------------------------------------------- |
| `name`         | string  | Yes      | Stage name (max 100 chars)                                           |
| `types`        | array   | Yes      | Available approval types                                             |
| `default`      | string  | Yes      | Default approval type                                                |
| `minReviewers` | integer | No       | Minimum reviewers required                                           |
| `reviewers`    | object  | No       | Pre-assigned reviewers (internal users)                              |
| `notifyList`   | object  | No       | Pre-assigned stage notifiers (internal users and/or external emails) |

#### Approval Types

Each stage must support one or more approval types:

| Type        | Description                          |
| ----------- | ------------------------------------ |
| `Unanimous` | All reviewers must approve           |
| `Majority`  | More than 50% must approve           |
| `Minimum`   | At least `minReviewers` must approve |

{% hint style="info" %}
The values shown here are the schema as stored and as accepted by the GraphQL API: `reviewers.users` and `notifyList.users` must be **UUID strings**. To resolve names to UUIDs, query your organization's members — see [RBAC → Get Organization Members with Roles](/advanced-topics/rbac#get-organization-members-with-roles). The web template editor accepts **email addresses** for internal users and resolves them to UUIDs on save, so manual lookup is only needed when authoring YAML by hand.
{% endhint %}

#### Reviewers Object

Pre-assign reviewers to a stage. Reviewers are internal users, specified as UUID strings.

```yaml
reviewers:
  users:
    - 123e4567-e89b-12d3-a456-426614174001
    - 123e4567-e89b-12d3-a456-426614174002
```

| Property | Type             | Required | Description                                                          |
| -------- | ---------------- | -------- | -------------------------------------------------------------------- |
| `users`  | array of strings | Yes      | Internal user UUIDs (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) |

**Note**: Each entry must be a UUID string referencing an existing user in the library's organization. External email addresses are not allowed here — use `notifyList.emails` instead. Duplicate IDs within a stage are automatically deduplicated.

#### NotifyList Object

Pre-assign notifiers to a stage. Notifiers are added to the stage's notify list when the change order is created but do not participate in the approval process. A notify list can include internal users, external email addresses, or both.

```yaml
notifyList:
  users:
    - 123e4567-e89b-12d3-a456-426614174003
  emails:
    - supplier@example.com
```

| Property | Type             | Required | Description                                                            |
| -------- | ---------------- | -------- | ---------------------------------------------------------------------- |
| `users`  | array of strings | No       | Internal user UUIDs (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`)   |
| `emails` | array of strings | No       | External recipient email addresses (for people without a Duro account) |

**Note**: Provide `users`, `emails`, or both. `users` follows the same UUID and organization-membership rules as `reviewers.users`; `emails` accepts any well-formed address and is not checked against the organization.

### Stage Actions (resolved, closed, onHold)

```yaml
resolved:
  actions: [string]
  resolutions:
    onapproval: [string]
    onrejection: [string]
    onwithdrawal: [string]
```

#### Resolution Types

| Value          | Description                        |
| -------------- | ---------------------------------- |
| `AUTO_CLOSE`   | Automatically transition to closed |
| `MANUAL_CLOSE` | Require manual closure             |

## Complete Example

```yaml
version: "1.0"
description: "Medical device change control workflow"
schema_type: "change_order_scheme"

details:
  info:
    groups:
      - name: "Change Classification"
        icon: "mdi-medical-bag"
        fields:
          - name: "change_category"
            type: "enum"
            label: "Change Category"
            options:
              - label: "Design Change"
                value: "design"
                description: "Modifications to product design"
              - label: "Process Change"
                value: "process"
                description: "Manufacturing process updates"
            default: "design"
            validations:
              required: true
          
          - name: "risk_level"
            type: "list"
            label: "Risk Categories"
            multiSelect: true
            options:
              - label: "Patient Safety"
                value: "safety"
              - label: "Product Performance"
                value: "performance"
              - label: "Regulatory Compliance"
                value: "regulatory"

      - name: "Impact Assessment"
        icon: "mdi-chart-line"
        fields:
          - name: "validation_required"
            type: "enum"
            label: "Validation Required"
            options:
              - label: "Full Validation"
                value: "full"
              - label: "Partial Validation"
                value: "partial"
              - label: "No Validation"
                value: "none"
            validations:
              required: true

stages:
  open:
    - name: "Engineering Review"
      types: ["Unanimous"]
      default: "Unanimous"
      minReviewers: 2
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174001
          - 123e4567-e89b-12d3-a456-426614174002
      notifyList:
        users:
          - 123e4567-e89b-12d3-a456-426614174003
        emails:
          - supplier@example.com

    - name: "Quality Review"
      types: ["Unanimous", "Majority"]
      default: "Unanimous"
      minReviewers: 1
      reviewers:
        users:
          - 123e4567-e89b-12d3-a456-426614174004

  resolved:
    resolutions:
      onapproval: ["AUTO_CLOSE"]
      onrejection: ["MANUAL_CLOSE"]

  closed: {}
```

## Validation Rules

The schema enforces these validation rules:

1. **Required Fields**: All fields marked as required must have values
2. **Pattern Matching**: Field names must match `^[a-zA-Z_][a-zA-Z0-9_]*$`
3. **Length Limits**:
   * Descriptions: 500 characters
   * Labels/Names: 100 characters
   * Placeholders: 200 characters
4. **Type Constraints**:
   * List/Enum fields must have at least one option
   * Only list fields can use `multiSelect`
5. **Stage Requirements**:
   * At least one open stage is required
   * Each stage must have a name, types array, and default type
   * Default type must be in the types array
6. **Participant Constraints**:
   * `reviewers.users` and `notifyList.users` must be arrays of UUID strings
   * Each user must exist in the system and belong to the library's organization
   * `notifyList.emails` accepts external email addresses and is not validated against the organization
   * Invalid participants are rejected at template upload

## Best Practices

1. **Use Semantic Names**: Choose descriptive names for fields and stages
2. **Provide Descriptions**: Help users understand field purposes
3. **Set Appropriate Validations**: Use min/max for numeric fields
4. **Configure Notifications**: Ensure stakeholders are informed
5. **Test Workflows**: Validate all paths through your workflow
6. **Version Control**: Track changes to workflow templates
7. **Document Decisions**: Use the description field to explain workflow design choices


# Change Order Validations

## Overview

Change Order Validations provide a comprehensive system for ensuring data integrity and enforcing business rules throughout your change management process. This powerful feature allows you to run both built-in system validations and custom validation rules that align with your organization's specific requirements.

## Why Validations Matter

In complex engineering environments, a single oversight can lead to costly errors, production delays, or compliance issues. The validation system helps you:

* **Prevent Errors Early**: Catch issues before changes are approved and implemented
* **Enforce Standards**: Ensure all changes meet your organization's requirements
* **Maintain Consistency**: Apply the same rules across all change orders
* **Provide Transparency**: Give reviewers clear visibility into validation results
* **Enable Custom Logic**: Create organization-specific validation rules

## Understanding Validation Types

The Duro platform provides two types of validations:

### System Validations

Built-in validations that run automatically to ensure fundamental data integrity:

* Items must not exist in other OPEN change orders (prevents conflicts)
* [`DCO` items must not have drifted from their last-released baseline](#dco-baseline-freeze)
* Required fields must be populated
* Data types must match expected formats
* Cross-references must be valid

#### DCO baseline freeze

Change orders of type [`DCO`](/core-concepts/change-orders#change-order-types) are revision-frozen, so a built-in validation guards their items against drifting from the last-released baseline. For every item on a DCO, it compares the item's **live** `status` and `revision` to its **last-released baseline** and fails the change order when they no longer match. It fails when any of the following holds:

* the live `status` differs from the baseline `status`;
* the live `revision` differs from the baseline `revision`;
* the item has no releasable baseline (it was never released), or its live component can't be found.

This validation runs only for DCOs — change orders of any other type skip it. Because it is checked both when the DCO is submitted and again when it closes, a DCO can't slip through if a concurrent change order releases one of its items in the meantime. A failure surfaces as [`DCO_STATUS_CHANGE_NOT_ALLOWED` / `DCO_REVISION_CHANGE_NOT_ALLOWED`](/advanced-topics/error-handling#dco-status-change-not-allowed-dco-revision-change-not-allowed).

### Custom Validations

JavaScript-based rules you create to enforce your specific business logic:

* Cost threshold checks
* Part number format validation
* Required approver verification
* Impact assessment requirements
* Custom field dependencies

## Running Validations

Validations can be triggered manually at any time during the `DRAFT` state of the change order lifecycle. Here's how to run validations and interpret the results:

### Basic Validation Query

```graphql
mutation ValidateChangeOrder($changeOrderId: ID!) {
  changeOrders {
    validate(id: $changeOrderId) {
      id
      isValid  # Overall validation result

      validationRun {
        id
        state  # PASS or FAIL
        summary {
          passCount
          failCount
          warningCount
        }

        results(pagination: { first: 50 }) {
          edges {
            node {
              name
              state
              errorMessage
            }
          }
        }
      }
    }
  }
}
```

### Example Response

```json
{
  "data": {
    "changeOrders": {
      "validate": {
        "id": "123e4567-e89b-12d3-a456-426614174000",
        "isValid": false,
        "validationRun": {
          "id": "987fcdeb-51a2-43d1-9876-543210fedcba",
          "state": "FAIL",
          "summary": {
            "passCount": 8,
            "failCount": 2,
            "warningCount": 1
          },
          "results": {
            "edges": [
              {
                "node": {
                  "name": "Items must not exist in other OPEN Change Orders",
                  "state": "FAIL",
                  "errorMessage": "Item P123-456 exists in Change Order CO-2024-001"
                }
              }
            ]
          }
        }
      }
    }
  }
}
```

## Accessing Validation History

Every validation run is stored, allowing you to track how validation results change over time as issues are resolved.

### Viewing Latest Validation Results

```graphql
query GetLatestValidation($changeOrderId: ID!) {
  changeOrders {
    get(filter: { ids: [$changeOrderId] }) {
      connection {
        edges {
          node {
            id
            name
            isValid

            latestValidationRun {
              id
              state
              createdAt
              createdBy {
                name
              }

              summary {
                passCount
                failCount
                warningCount
              }

              # Get detailed results with logs
              results(pagination: { first: 100 }) {
                edges {
                  node {
                    name
                    validationType  # SYSTEM or CUSTOM
                    state          # PASS, FAIL, or WARNING
                    onFailure      # ERROR or WARNING
                    errorMessage

                    # Access validation logs for debugging
                    logs {
                      error {
                        message
                      }
                      info {
                        message
                      }
                      all {
                        message
                        type
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
```

### Viewing Historical Validation Runs

Track how validation results have changed over time:

```graphql
query GetValidationHistory($changeOrderId: ID!) {
  changeOrders {
    get(filter: { ids: [$changeOrderId] }) {
      connection {
        edges {
          node {
            id

            # Get all historical validation runs
            validationRuns(pagination: { first: 10 }) {
              edges {
                node {
                  id
                  collectionId
                  state
                  createdAt
                  createdBy {
                    name
                  }

                  summary {
                    passCount
                    failCount
                    warningCount
                  }

                  # Can drill into specific failed validations
                  results(pagination: { first: 100 }) {
                    edges {
                      node {
                        name
                        state
                        errorMessage
                      }
                    }
                  }
                }
              }
              totalCount
            }
          }
        }
      }
    }
  }
}
```

## Understanding Validation Results

Each validation result provides detailed information to help you understand and resolve issues:

### Validation States

* **PASS**: The validation succeeded without issues
* **FAIL**: The validation failed and must be resolved
* **WARNING**: The validation found potential issues that should be reviewed

### OnFailure Behavior

* **ERROR**: Blocks the change order from proceeding (hard stop)
* **WARNING**: Alerts reviewers but doesn't block progress (soft warning)

### Validation Logs

Each validation generates detailed logs that help with debugging:

```graphql
logs {
  # Informational messages about validation execution
  info {
    message
  }

  # Warnings about potential issues
  warn {
    message
  }

  # Error details when validation fails
  error {
    message
  }

  # All logs in chronological order
  all {
    message
    type  # "info", "warn", "error", or "log"
  }
}
```

## Working with Custom Validations

Custom validations allow you to implement organization-specific business rules. When a custom validation runs, you can access both the validation result and the underlying rule definition:

```graphql
query GetCustomValidationDetails($changeOrderId: ID!) {
  changeOrders {
    get(filter: { ids: [$changeOrderId] }) {
      connection {
        edges {
          node {
            latestValidationRun {
              results(pagination: { first: 50 }) {
                edges {
                  node {
                    name
                    validationType
                    state
                    errorMessage

                    # Only populated for CUSTOM validations
                    validationRule {
                      id
                      name
                      type
                      version
                      code  # The JavaScript validation code
                      library {
                        id
                        name
                      }
                    }

                    # Debug custom validation execution
                    logs {
                      all {
                        message
                        type
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
```

## Practical Examples

### Example 1: Check Only Failed Validations

When debugging validation failures, focus on just the problems:

```graphql
query GetFailedValidations($changeOrderId: ID!) {
  changeOrders {
    get(filter: { ids: [$changeOrderId] }) {
      connection {
        edges {
          node {
            id
            name
            isValid

            latestValidationRun {
              state
              summary {
                failCount
                warningCount
              }

              # Fetch all results, then filter client-side
              results(pagination: { first: 100 }) {
                edges {
                  node {
                    name
                    state
                    errorMessage
                    logs {
                      error {
                        message
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
```

Then filter the results in your application code:

```javascript
const failedValidations = data.changeOrders.get.connection.edges[0]
  .node.latestValidationRun.results.edges
  .filter(edge => edge.node.state === 'FAIL' || edge.node.state === 'WARNING');
```

### Example 2: Monitor Validation Progress

Track validation improvements over multiple runs:

```graphql
query CompareValidationRuns($changeOrderId: ID!) {
  changeOrders {
    get(filter: { ids: [$changeOrderId] }) {
      connection {
        edges {
          node {
            # Current state
            latestValidationRun {
              createdAt
              summary {
                passCount
                failCount
                warningCount
              }
            }

            # Historical comparison
            validationRuns(pagination: { first: 5 }) {
              edges {
                node {
                  createdAt
                  summary {
                    passCount
                    failCount
                    warningCount
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
```

### Example 3: Validation Integration Workflow

Here's a complete workflow for integrating validations into your change order process:

```javascript
// 1. Create or update a change order
const changeOrderId = await createChangeOrder(changeOrderData);

// 2. Run validations
const validationResult = await runValidation(changeOrderId);

// 3. Check if valid
if (!validationResult.isValid) {
  // 4. Get detailed failure information
  const failures = validationResult.validationRun.results.edges
    .filter(edge => edge.node.state !== 'PASS')
    .map(edge => ({
      name: edge.node.name,
      error: edge.node.errorMessage,
      severity: edge.node.onFailure
    }));

  // 5. Display failures to user
  displayValidationErrors(failures);

  // 6. Allow user to fix issues
  await promptUserToResolveIssues(failures);

  // 7. Re-run validations after fixes
  const retryResult = await runValidation(changeOrderId);

  if (retryResult.isValid) {
    console.log('All validations passed!');
  }
}

// 8. Proceed with approval workflow only if valid
if (validationResult.isValid) {
  await submitForApproval(changeOrderId);
}
```

## Best Practices

### 1. Run Validations Early and Often

Don't wait until the approval stage to run validations. Run them:

* After initial change order creation
* After any significant updates
* Before submitting for approval
* After resolving validation failures

### 2. Provide Clear Error Messages

When creating custom validations, ensure error messages are actionable:

* ✅ Good: "Part number ABC-123 must have an associated drawing document"
* ❌ Avoid: "Validation failed"

### 3. Use Warnings Appropriately

Reserve warnings for issues that should be reviewed but don't necessarily block progress:

* Missing optional documentation
* Unusual but acceptable values
* Recommendations for best practices

### 4. Monitor Validation Trends

Track validation patterns across change orders to identify:

* Common failure points in your process
* Training opportunities for users
* Potential system improvements

### 5. Leverage Validation Logs

Use the detailed logs to:

* Debug custom validation logic
* Understand validation execution flow
* Provide context to users about failures

## Troubleshooting

### Common Issues

#### Validation Runs But Shows No Results

Ensure you're requesting the results field with proper pagination:

```graphql
validationRun {
  results(pagination: { first: 100 }) {  # Don't forget pagination
    edges {
      node {
        name
        state
      }
    }
  }
}
```

#### Custom Validation Not Running

Verify that:

* The validation rule is active in your library
* The validation rule code is syntactically correct
* The change order meets the criteria for the validation to run

#### Inconsistent Validation Results

Check for:

* Data changes between validation runs
* Updates to validation rules
* Different validation contexts (some validations may be conditional)

## Next Steps

1. Review your organization's validation requirements
2. Implement custom validations for your specific needs
3. Integrate validation checks into your change order workflow
4. Monitor validation metrics to improve your process

For a complete guide to writing custom validation rules — including the payload structure, attribute access patterns, and practical examples — see [Authoring Custom Validations](/library-configuration/authoring-custom-validations).


# Authoring Custom Validations

Custom validations are JavaScript functions that run during change order validation. They receive the full change order context — items, attributes, approval stages, custom fields — and return a pass/fail result with a message. You write the logic; Duro handles execution in a secure sandbox.

## How It Works

When a change order is validated, Duro sends each custom validation rule to an isolated execution environment. Your code receives a `data` object containing the change order and its items, runs your logic, and returns a result. The entire round-trip happens within a 30-second timeout.

The basic contract is simple: export a `validate` function that accepts the data payload and returns `{ valid, message }`.

```javascript
exports.validate = async function(data) {
  // data.change_order — the change order metadata
  // data.items — the components in the change order

  // Your logic here...

  return {
    valid: true,      // or false
    message: 'Human-readable result'
  };
};
```

## The Validation Payload

Your validation function receives a single `data` argument with two top-level properties:

* `data.change_order` — the change order metadata, approval stages, custom fields
* `data.items` — the components in the change order, with full attribute data

### Change Order Fields

| Field         | Type   | Description                                           |
| ------------- | ------ | ----------------------------------------------------- |
| `id`          | string | UUID of the change order                              |
| `name`        | string | Change order name/title                               |
| `description` | string | Description text (can be null)                        |
| `status`      | string | `draft`, `open`, `resolved`, `closed`, or `on_hold`   |
| `resolution`  | string | `pending`, `approved`, `rejected`, or `withdrawn`     |
| `createdBy`   | object | `{ id, name, primaryEmail }` of the creator           |
| `updatedBy`   | object | `{ id, name, primaryEmail }` of the last editor       |
| `contents`    | array  | Custom form fields with values and validation rules   |
| `stages`      | array  | Approval stages with reviewers and notification lists |

### Item Fields

Each entry in `data.items` combines the change order item data with its component information:

| Field              | Type   | Description                                                           |
| ------------------ | ------ | --------------------------------------------------------------------- |
| `id`               | string | Change order item UUID                                                |
| `itemId`           | string | Component UUID                                                        |
| `name`             | string | Component name                                                        |
| `description`      | string | Component description                                                 |
| `category`         | object | `{ id, name, attributes }` — category info with attribute definitions |
| `status`           | object | `{ id, name, mapsTo, color }` — current status                        |
| `version`          | number | Component version number                                              |
| `revisionValue`    | string | Current revision (e.g., `"A"`, `"B"`)                                 |
| `state`            | string | `RELEASED` or `MODIFIED`                                              |
| `attributes`       | object | Human-readable attribute values keyed by name                         |
| `attributeValues`  | object | Raw attribute values keyed by UUID (legacy)                           |
| `proposedRevision` | string | New revision value                                                    |
| `proposedStatus`   | object | `{ id, name }` — new status                                           |

### Component Attributes

Each item carries attribute data in two forms.

**`item.attributes`** — Human-readable, keyed by name. This is what you'll use most of the time.

```javascript
item.attributes['Manufacturer']     // "Acme Co" or null
item.attributes['Mass']             // { value: "25", unit: "g" } or null
item.attributes['Cost']             // { value: 42.50, unit: "USD" } or null
item.attributes['Support Phone']    // "+1-555-0123" or null
```

All attributes assigned to the item's category appear in this map. Unset attributes are `null` — so you can distinguish "not set" from "attribute doesn't exist on this category."

**`item.category.attributes`** — Attribute definitions for the category. Useful when you need to know what attributes exist, their types, or iterate programmatically.

```javascript
item.category.attributes.forEach(attr => {
  console.info(`${attr.name} (${attr.type}): ${item.attributes[attr.name]}`);
});
```

Each definition includes: `id`, `name`, `type`, `source`, `sourceKey`, `unit`.

**`item.attributeValues`** — Raw storage format with UUID keys. Kept for backward compatibility. You shouldn't need this unless you're working with attribute IDs directly.

### Value Shapes

| Attribute Type                           | Example `item.attributes['Name']` |
| ---------------------------------------- | --------------------------------- |
| STRING, URL, EMAIL, PHONE, DATE, BOOLEAN | `"Acme Co"` (unwrapped primitive) |
| NUMBER (simple)                          | `42` (unwrapped number)           |
| NUMBER (measured -- mass, length)        | `{ value: "25", unit: "g" }`      |
| NUMBER (currency)                        | `{ value: 42.50, unit: "USD" }`   |
| LIST                                     | `"Each"` (unwrapped string)       |
| Unset                                    | `null`                            |

## Writing Your First Validation

Create a `.js` file that exports a `validate` function. The function must be `async` (or return a Promise) and return an object with `valid` (boolean) and `message` (string).

### Example: Block Components Missing a Manufacturer

```javascript
exports.validate = async function(data) {
  const missing = data.items.filter(item => !item.attributes['Manufacturer']);

  if (missing.length > 0) {
    const names = missing.map(item => item.name).join(', ');
    return {
      valid: false,
      message: `${missing.length} component(s) missing Manufacturer: ${names}`
    };
  }

  return { valid: true, message: 'All components have a manufacturer' };
};
```

That's the whole file. No imports, no boilerplate, no class wiring. Duro loads it, calls `exports.validate`, and records the result.

## Common Patterns

### Checking Attribute Values

```javascript
exports.validate = async function(data) {
  const problems = [];

  for (const item of data.items) {
    const cost = item.attributes['Cost'];
    if (cost && cost.value > 10000) {
      problems.push(`${item.name} has cost $${cost.value} (exceeds $10,000)`);
    }
  }

  if (problems.length > 0) {
    return { valid: false, message: problems.join('; ') };
  }
  return { valid: true, message: 'All costs within threshold' };
};
```

### Checking Category-Specific Rules

Use `item.category.name` to apply rules only to certain component types:

```javascript
exports.validate = async function(data) {
  for (const item of data.items) {
    if (item.category.name === 'Capacitor' && !item.attributes['Capacitance']) {
      return {
        valid: false,
        message: `Capacitor "${item.name}" is missing the Capacitance attribute`
      };
    }
  }
  return { valid: true, message: 'Category-specific checks passed' };
};
```

### Validating Approval Stages

The `data.change_order.stages` array gives you full visibility into the approval workflow:

```javascript
exports.validate = async function(data) {
  for (const stage of data.change_order.stages || []) {
    if (!stage.reviewers || stage.reviewers.length < 2) {
      return {
        valid: false,
        message: `Stage "${stage.name}" needs at least 2 reviewers`
      };
    }
  }
  return { valid: true, message: 'All stages have sufficient reviewers' };
};
```

### Enforcing Naming Conventions

```javascript
exports.validate = async function(data) {
  const pattern = /^ECO-\d{4}-\d{2,3}$/;
  if (!pattern.test(data.change_order.name)) {
    return {
      valid: false,
      message: `Name "${data.change_order.name}" must match pattern ECO-YYYY-NNN`
    };
  }
  return { valid: true, message: 'Name format is valid' };
};
```

### Working with Measured Values

Measured attributes (mass, length, etc.) return `{ value, unit }` rather than a bare number. Parse accordingly:

```javascript
exports.validate = async function(data) {
  for (const item of data.items) {
    const mass = item.attributes['Mass'];
    if (mass && parseFloat(mass.value) > 1000) {
      console.warn(`${item.name} exceeds 1kg: ${mass.value}${mass.unit}`);
      return {
        valid: false,
        message: `${item.name} mass is ${mass.value}${mass.unit} — exceeds 1kg limit`
      };
    }
  }
  return { valid: true, message: 'All components within mass limits' };
};
```

## Logging

All `console` methods are captured and visible in the validation results:

* `console.info()` — informational messages about execution
* `console.log()` — general-purpose logging
* `console.warn()` — warnings about potential issues
* `console.error()` — error details

Use logging liberally. It costs nothing and makes debugging much easier when a validation fails unexpectedly.

```javascript
console.info(`Checking ${data.items.length} components`);
console.info(`Change order: ${data.change_order.name}`);

for (const item of data.items) {
  console.log(`Processing ${item.name} (${item.category.name})`);
}
```

Logs are accessible via the GraphQL API on each validation result's `logs` field — see [Change Order Validations](/library-configuration/change-order-validations) for the query structure.

## Linking to Entities

Duro supports a markdown-inspired syntax for creating clickable links in validation messages and logs. When you reference a component or change order, wrap it in a link so users can click straight through to it in the Duro UI.

### Syntax

```
[display text](protocol:type/id)
```

### Supported Link Types

| Entity       | Syntax                      | Example                                 |
| ------------ | --------------------------- | --------------------------------------- |
| Component    | `[text](item:component/id)` | `[CAP-001](item:component/abc-123)`     |
| Assembly     | `[text](item:assembly/id)`  | `[Main Board](item:assembly/xyz-789)`   |
| Change Order | `[text](co:changeorder/id)` | `[ECO-2024-001](co:changeorder/co-123)` |
| User         | `[text](user:profile/id)`   | `[Jane Smith](user:profile/user-456)`   |

### Helper Function

A small helper keeps link construction consistent:

```javascript
function componentLink(item) {
  const display = item.name || item.eid || item.itemId;
  return `[${display}](item:component/${item.itemId})`;
}

// In your validation:
console.error(`${componentLink(item)} is missing a description`);
```

Links degrade gracefully — if the UI can't parse them, users still see the display text.

## Error vs Warning Mode

The `onFailure` setting controls what happens when your validation returns `{ valid: false }`:

| Mode      | Behavior                                            |
| --------- | --------------------------------------------------- |
| `error`   | Blocks the change order from proceeding (hard stop) |
| `warning` | Alerts reviewers but does not block progress        |

This is a configuration setting, not something you control in JavaScript. Your code is identical either way — you return `{ valid: false, message }` and the platform decides whether to block or warn based on the mode.

{% hint style="info" %}
If you manage validations through the [library-validations-template](https://github.com/duronext/library-validations-template), the mode is set in `validations.yaml` via the `onFailure` field.
{% endhint %}

## Execution Environment

{% hint style="info" %}
Validations run in an isolated sandbox with a 30-second timeout. No filesystem access, no network access (unless your admin has enabled the fetch proxy). All console output is captured.
{% endhint %}

Your code cannot:

* Read or write files
* Make HTTP requests (by default)
* Import Node.js built-in modules
* Access environment variables

If your validation needs external data, talk to your Duro admin about enabling the fetch proxy.

## Managing Validations via the API

You can create, update, and list custom validation rules through the GraphQL API. The library is resolved from your API key — you don't need to specify it explicitly.

### Creating a Validation Rule

```graphql
mutation CreateValidation($input: CreateValidationRuleInput!) {
  validations {
    create(input: $input) {
      id
      name
    }
  }
}
```

Variables:

```json
{
  "input": {
    "name": "Block: Missing Manufacturer",
    "description": "Rejects change orders where any component is missing a Manufacturer attribute",
    "code": "exports.validate = async function(data) {\n  const missing = data.items.filter(item => !item.attributes['Manufacturer']);\n  if (missing.length > 0) {\n    return { valid: false, message: missing.map(i => i.name).join(', ') + ' missing Manufacturer' };\n  }\n  return { valid: true, message: 'All components have a manufacturer' };\n};",
    "type": "custom",
    "onFailure": "ERROR",
    "isActive": true,
    "version": "1.0.0"
  }
}
```

| Field         | Type    | Description                                      |
| ------------- | ------- | ------------------------------------------------ |
| `name`        | string  | Display name for the validation rule             |
| `description` | string  | What the rule checks                             |
| `code`        | string  | The JavaScript source code                       |
| `type`        | string  | Always `"custom"`                                |
| `onFailure`   | string  | `"ERROR"` (blocks) or `"WARNING"` (non-blocking) |
| `isActive`    | boolean | Whether the rule runs during validation          |
| `version`     | string  | Semantic version for tracking changes            |

### Updating a Validation Rule

```graphql
mutation UpdateValidation($id: ID!, $input: UpdateValidationRuleInput!) {
  validations {
    update(id: $id, input: $input) {
      id
      name
      version
    }
  }
}
```

You can update any combination of `code`, `description`, `onFailure`, `isActive`, and `version`. The `name` and `type` cannot be changed after creation.

### Listing Validation Rules

```graphql
query ListValidations {
  validations {
    list {
      edges {
        node {
          id
          name
          description
          code
          onFailure
          isActive
          version
        }
      }
    }
  }
}
```

### Deleting a Validation Rule

```graphql
mutation DeleteValidation($id: ID!) {
  validations {
    delete(id: $id)
  }
}
```

{% hint style="warning" %}
Deletion is permanent. If you want to temporarily disable a rule, update `isActive` to `false` instead.
{% endhint %}

## Source-Controlled Validations

For teams that want to manage validations in version control, Duro provides the [library-validations-template](https://github.com/duronext/library-validations-template) — a GitHub template repository you can fork for your library. It gives you:

* A `validations.yaml` file defining each rule's name, description, version, active state, and failure mode
* A `validations/` directory for your JavaScript files
* A sync script that pushes your local definitions to the Duro API via the mutations above
* A GitHub Actions workflow that syncs automatically on push to `main`

Fork the template, add your API key as a GitHub secret (`DURO_LIBRARY_API_KEY`), and every push to `main` will sync your validation rules to your Duro library. The sync is additive — it creates new rules, updates changed ones, and deactivates rules removed from the config (it never deletes).

This approach lets you review validation changes in pull requests, track history in git, and deploy consistently across libraries. The repository also includes a `CLAUDE.md` file that enables AI-assisted validation authoring — describe what you want in natural language and Claude Code generates the JavaScript and YAML config.

## Best Practices

**Keep validations focused.** One validation, one concern. A rule that checks manufacturer data and also enforces naming conventions should be two separate validations. Smaller validations are easier to debug, toggle, and explain to users.

**Provide clear messages.** Your message is the first thing a user sees when something fails. Tell them what's wrong and which components are affected — not just "validation failed."

```javascript
// Helpful
`3 components missing Manufacturer: CAP-001, RES-042, IC-100`

// Not helpful
`Validation failed`
```

**Use component links.** When referencing specific components in messages or logs, use the `[name](item:component/id)` syntax so users can click through to the problem.

**Handle null and undefined gracefully.** Attributes can be `null` (unset), items can have missing fields, and the `stages` array can be empty. Defensive checks prevent runtime errors that surface as cryptic failures.

```javascript
// Defensive
const manufacturer = item.attributes['Manufacturer'];
if (manufacturer && manufacturer.toLowerCase() === 'acme co') { ... }

// Fragile — throws if attribute is null
if (item.attributes['Manufacturer'].toLowerCase() === 'acme co') { ... }
```

**Log diagnostic info.** Drop `console.info` calls at the start of your validation to record how many items you're checking, which categories are present, and any thresholds you're using. When something goes wrong, the logs tell the story.

**Return a message on success too.** A passing message like `"All 12 components have a manufacturer"` gives users confidence that the check actually ran and inspected their data.


# Developer Community

Join the Duro developer community to get help, share ideas, and stay updated.

### Community Resources

* Join us on Slack: Reach out to us at <developers@durohub.com> and we'll add you to the Slack workspace.
* [Stack Overflow Tag](https://stackoverflow.com/questions/tagged/duro-api)
* [Check out our roadmap](https://next.durohub.com/roadmap)

### Contributing

We welcome community contributions:

* Bug reports
* Feature requests
* Documentation improvements
* Code examples
* Integration tutorials

### Stay Updated

* [API Changelog](https://developer.durohub.com/changelog)
* [Blog](https://durolabs.co/blog)
* [LinkedIn](https://www.linkedin.com/company/durolabs)
* Newsletter subscription

### Support Channels

* Technical Support: <support@durohub.com>
* API Status: [status.durohub.com](https://status.durohub.com)
* Security Issues: <security@durohub.com>

### Community Guidelines

1. Be respectful and inclusive
2. Share knowledge freely
3. Report bugs responsibly
4. Follow security best practices
5. Protect user privacy

{% hint style="info" %}
Remember to check the documentation and existing discussions before posting questions.
{% endhint %}


