---
name: fluent-sourcing
description: "Comprehensive reference for the Fluent Commerce Responsive Sourcing Framework. Covers sourcing profiles, strategies, conditions, criteria, scoring algorithms, permutation search, settings schema, workflow integration, custom extensions, and operational GraphQL patterns. Triggers on \"sourcing\", \"sourcing profile\", \"sourcing strategy\", \"sourcing condition\", \"sourcing criteria\", \"fulfilment allocation\", \"location scoring\", \"order sourcing\", \"where to fulfil\"."
user-invocable: true
read-only: true
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
argument-hint: "[--profile <PROFILE>] [--retailer <RETAILER_REF>] [--operation query|create|debug]"
cursor-tier: manual
---

# Fluent Sourcing Framework Reference

Expert reference for the Fluent Commerce Responsive Sourcing Framework (v2.1+). Covers sourcing profiles, strategies, conditions, criteria, scoring algorithms, permutation search, settings, workflow integration, custom extensions, and operational GraphQL patterns.

**Source:** `util-sourcing-2.1.0.jar` decompiled analysis + Fluent Commerce documentation.

## Ownership Boundary

| Task | This Skill | Chain To |
|------|-----------|----------|
| Understand sourcing concepts, scoring, algorithms | Yes | — |
| Generate sourcing profile JSON configurations | Yes | — |
| Debug why a location was selected/excluded | Yes | `/fluent-trace` for event-level forensics |
| Build custom sourcing condition/criterion Java code | Yes (skeleton) | `/fluent-rule-scaffold` for full module wiring |
| Create/modify workflows that invoke sourcing | No | `/fluent-workflow-builder` |
| Deploy sourcing-related settings | No | `/fluent-settings` |
| Query live sourcing profiles via MCP | No | `/fluent-mcp-tools` |
| Trace sourcing event execution | No | `/fluent-trace` |
| Configure retailer locations/networks | No | `/fluent-retailer-config` |
| Run E2E sourcing tests | No | `/fluent-e2e-test` |

## Skill Type: Reference

This is a **reference skill** — it provides domain knowledge and query patterns but does not modify code or Fluent environments. Use it to understand sourcing concepts, generate configuration JSON, and debug allocation decisions. Implementation changes (workflow edits, rule scaffolding, settings) should be delegated to the appropriate implementation skills listed in the Ownership Boundary above.

## Related Knowledge

- `knowledge/platform/create-order-reference.md` — how FulfilmentChoice entities are created inline with orders, item-to-FC linkage, and ORDER::MULTI patterns. Sourcing operates on FulfilmentChoice entities created by the order intake mutation.

## When to Use

- **"How does sourcing work?"** — Conceptual Architecture section
- **"What sourcing conditions/criteria are available?"** — Conditions and Criteria sections
- **"Why was this location selected/excluded?"** — Debugging & Troubleshooting section
- **"How do I configure a sourcing profile?"** — Sourcing Profiles section
- **"How do I add custom sourcing logic?"** — Custom Extension Guide section
- **"What settings control sourcing?"** — Settings Reference section
- **"How does sourcing connect to workflows?"** — Workflow Integration section
- **"How does the scoring algorithm work?"** — Scoring & Normalization subsection

## Conceptual Architecture

### Evaluation Flow

```mermaid
graph TD
    Start[Order Sourcing Request] --> Load[Load Sourcing Profile]
    Load --> Strategies[Evaluate Strategies by Priority]
    Strategies --> Condition{All Conditions Met?}
    Condition -- No --> NextStrategy[Next Strategy]
    NextStrategy --> Strategies
    Condition -- Yes --> Type{Primary or Fallback?}
    Type -- Primary --> MustFull[Must Source ALL Items]
    Type -- Fallback --> CanPartial[Can Source PARTIAL Items]
    MustFull --> Criteria[Execute Criteria Chain]
    CanPartial --> Criteria
    Criteria --> Exclusion[Exclusion Criteria: Remove Locations score = -1.0]
    Exclusion --> Scoring[Sorter Criteria: Score Remaining Locations]
    Scoring --> Normalize[Normalize Scores to 0.0 - 1.0]
    Normalize --> Sort[Sort by Final Score DESC]
    Sort --> Split{Items Need Split?}
    Split -- Yes maxSplit > 1 --> Permutation[Permutation Search]
    Split -- No single location --> Select[Select Top Location]
    Permutation --> Plan[Build Fulfilment Plan]
    Select --> Plan
    Plan --> End[Return FulfilmentPlan]
```

### Entity Model

| Entity | Parent | Description | Key Fields |
|--------|--------|-------------|------------|
| **SourcingProfile** | — | Root container for sourcing configuration | `ref`, `status`, `catalogueRef`, `networkRef`, `maxSplit`, `virtualCatalogueRef` |
| **SourcingStrategy** | SourcingProfile | A prioritized rule definition with conditions and criteria | `ref`, `priority`, `status`, `type` (PRIMARY/FALLBACK) |
| **SourcingCondition** | SourcingStrategy | Boolean gatekeeper — determines IF a strategy applies | `name`, `type`, `params` |
| **SourcingCriterion** | SourcingStrategy | Numeric scorer/filter — EXCLUDES or RANKS a location | `name`, `type`, `params` |

### Profile Lifecycle

Profiles follow a versioned lifecycle:

| Status | Description | Can Be Evaluated? |
|--------|-------------|-------------------|
| `DRAFT` | Under construction | No |
| `ACTIVE` | Live — used by workflow rules | Yes |
| `INACTIVE` | Archived — no longer evaluated | No |

Only **one version** of a profile (by ref) can be `ACTIVE` at a time. Activating a new version automatically deactivates the previous one.

### SourcingContext Model

The `SourcingContext` is the runtime data object passed to conditions and criteria. It contains:

| Path | Type | Description |
|------|------|-------------|
| `order` | Order | The full order entity |
| `order.totalPrice` | Float | Order total value |
| `order.attributes[]` | List | Order-level custom attributes |
| `fulfilmentChoice` | Object | Selected fulfilment option |
| `fulfilmentChoice.type` | String | e.g., `HD`, `CC`, `SFS` |
| `fulfilmentChoice.deliveryAddress` | Address | Delivery destination (lat/lng for distance) |
| `items[]` | List | Order items to source |
| `items[].productRef` | String | Product reference |
| `items[].requestedQuantity` | Integer | Quantity needed |
| `locations[]` | List | Candidate locations from network |
| `locations[].ref` | String | Location reference |
| `locations[].type` | String | e.g., `WAREHOUSE`, `STORE` |
| `locations[].address` | Address | Location coordinates |
| `locations[].attributes[]` | List | Location custom attributes |
| `inventoryPositions[]` | List | Stock data per location per product |

Conditions use JSON path expressions to navigate this model. For example, `fulfilmentChoice.type` extracts the delivery type, `order.attributes[?(@.name=='priority')].value` extracts a custom attribute.

## Sourcing Profiles

### Profile Structure

A Sourcing Profile is configured as a GraphQL entity and loaded at runtime by the `CreateFulfilmentWithSourcingProfile` workflow rule.

```json
{
  "ref": "SOURCING_PROFILE_HD",
  "status": "ACTIVE",
  "retailerId": 1,
  "catalogueRef": "DEFAULT:1",
  "virtualCatalogueRef": "FC:DEFAULT:1",
  "networkRef": "HD_NETWORK",
  "maxSplit": 2,
  "sourcingStrategies": [
    {
      "ref": "STRATEGY_HD_STANDARD",
      "priority": 10,
      "status": "ACTIVE",
      "type": "PRIMARY",
      "sourcingConditions": [],
      "sourcingCriteria": []
    }
  ]
}
```

**Field Reference:**

| Field | Required | Description |
|-------|----------|-------------|
| `ref` | Yes | Unique identifier for the profile |
| `status` | Yes | `DRAFT`, `ACTIVE`, or `INACTIVE` |
| `retailerId` | Yes | Owning retailer ID |
| `catalogueRef` | No | Product catalogue for inventory lookup |
| `virtualCatalogueRef` | No | Virtual catalogue (overrides catalogueRef) |
| `networkRef` | No | Default network for location candidates |
| `maxSplit` | No | Maximum number of fulfilment locations (default: 1) |
| `sourcingStrategies` | Yes | Ordered list of strategies |

### Create a Sourcing Profile

See the **GraphQL Operations** section for the full `createSourcingProfile` mutation.

### Activate a Sourcing Profile

Activation is typically done by updating the profile status to `ACTIVE`. See **GraphQL Operations > Update & Activate**.

## Sourcing Strategies

### Primary vs Fallback

| Aspect | Primary | Fallback |
|--------|---------|----------|
| **Type** | `PRIMARY` | `FALLBACK` |
| **Requirement** | Must source ALL order items | Can source PARTIAL items |
| **Evaluation** | Tried first (lower priority number = higher priority) | Tried only if no Primary strategy can source all items |
| **Result** | Single fulfilment plan for entire order | May produce multiple partial fulfilments |
| **Use Case** | Standard fulfilment from one location | Split shipment, partial availability |

### Strategy Configuration

| Field | Type | Description |
|-------|------|-------------|
| `ref` | String | Unique strategy identifier |
| `priority` | Integer | Evaluation order (lower = first). Must be unique within a profile. |
| `status` | String | `ACTIVE` or `INACTIVE` |
| `type` | String | `PRIMARY` or `FALLBACK` |
| `sourcingConditions` | Array | Conditions that must ALL pass for this strategy to apply |
| `sourcingCriteria` | Array | Criteria chain for filtering and ranking locations |

### Multi-Strategy Example

```json
{
  "ref": "SOURCING_PROFILE_MULTI",
  "maxSplit": 3,
  "sourcingStrategies": [
    {
      "ref": "HD_EXPRESS",
      "priority": 10,
      "status": "ACTIVE",
      "type": "PRIMARY",
      "sourcingConditions": [
        {
          "name": "IsExpress",
          "type": "fc.sourcing.condition.path",
          "params": {
            "path": "fulfilmentChoice.type",
            "operator": "equals",
            "value": "HD_EXPRESS"
          }
        }
      ],
      "sourcingCriteria": [
        {
          "name": "NearbyOnly",
          "type": "fc.sourcing.criterion.locationDistanceExclusion",
          "params": { "value": 25.0, "valueUnit": "KILOMETRES" }
        },
        {
          "name": "ClosestFirst",
          "type": "fc.sourcing.criterion.locationDistance"
        }
      ]
    },
    {
      "ref": "HD_STANDARD",
      "priority": 20,
      "status": "ACTIVE",
      "type": "PRIMARY",
      "sourcingConditions": [
        {
          "name": "IsHD",
          "type": "fc.sourcing.condition.path",
          "params": {
            "path": "fulfilmentChoice.type",
            "operator": "in",
            "value": ["HD", "HD_EXPRESS"]
          }
        }
      ],
      "sourcingCriteria": [
        {
          "name": "HasStock",
          "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion"
        },
        {
          "name": "ByDistance",
          "type": "fc.sourcing.criterion.locationDistance"
        },
        {
          "name": "ByCapacity",
          "type": "fc.sourcing.criterion.locationDailyCapacity"
        }
      ]
    },
    {
      "ref": "HD_FALLBACK_SPLIT",
      "priority": 100,
      "status": "ACTIVE",
      "type": "FALLBACK",
      "sourcingConditions": [],
      "sourcingCriteria": [
        {
          "name": "HasStock",
          "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion"
        },
        {
          "name": "ByStock",
          "type": "fc.sourcing.criterion.inventoryAvailability"
        }
      ]
    }
  ]
}
```

## Sourcing Conditions

### Condition Model

Conditions are boolean gates on a strategy. **ALL conditions must pass** (AND logic) for the strategy to be evaluated. If any condition fails, the engine skips to the next strategy.

There is one built-in condition type: `fc.sourcing.condition.path`.

**Class:** `com.fluentcommerce.util.sourcing.condition.DefaultSourcingCondition`

This condition extracts a value from the `SourcingContext` using a JSON path expression, then applies an operator against an expected value.

### Operators

(Source: `SourcingConditionOperatorRegistry.java`)

| Operator | Description | Value Type | Example |
|----------|-------------|------------|---------|
| `equals` | Exact match (case-sensitive) | Any | `"value": "HD"` |
| `not_equals` | Not equal | Any | `"value": "CC"` |
| `greater_than` | Numeric greater than | Number | `"value": 100.0` |
| `greater_than_or_equals` | Numeric >= | Number | `"value": 50` |
| `less_than` | Numeric less than | Number | `"value": 10` |
| `less_than_or_equals` | Numeric <= | Number | `"value": 5` |
| `in` | Value is in list | Array | `"value": ["HD", "CC"]` |
| `not_in` | Value is not in list | Array | `"value": ["PICKUP"]` |
| `between` | Numeric range (inclusive) | Array[2] | `"value": [10, 100]` |
| `exists` | Path exists (non-null) | Boolean | `"value": true` |

> **Important:** Operators are **case-sensitive, lowercase**. Using `EQUALS` or `Equals` will not match.

### conditionScope

When the JSON path resolves to an **array** (e.g., `items[].productRef`), the `conditionScope` parameter controls how the condition evaluates:

| Scope | Behavior |
|-------|----------|
| `ALL` | Every element in the array must match |
| `ANY` | At least one element must match |
| `NONE` | No element may match |

If `conditionScope` is omitted on a non-array path, it is ignored.

### Condition Examples

**Simple equality:**
```json
{
  "name": "IsHomeDelivery",
  "type": "fc.sourcing.condition.path",
  "params": {
    "path": "fulfilmentChoice.type",
    "operator": "equals",
    "value": "HD"
  }
}
```

**Numeric range:**
```json
{
  "name": "HighValueOrder",
  "type": "fc.sourcing.condition.path",
  "params": {
    "path": "order.totalPrice",
    "operator": "greater_than",
    "value": 200.0
  }
}
```

**List membership:**
```json
{
  "name": "IsDeliveryType",
  "type": "fc.sourcing.condition.path",
  "params": {
    "path": "fulfilmentChoice.type",
    "operator": "in",
    "value": ["HD", "HD_EXPRESS", "SFS"]
  }
}
```

**Custom attribute with exists:**
```json
{
  "name": "HasPriority",
  "type": "fc.sourcing.condition.path",
  "params": {
    "path": "order.attributes[?(@.name=='priority')].value",
    "operator": "exists",
    "value": true
  }
}
```

### Custom Conditions

To add custom condition logic beyond `fc.sourcing.condition.path`, see the **Custom Extension Guide** section below. Custom conditions implement the `SourcingCondition` interface and are registered in the `SourcingConditionTypeRegistry`.

## Sourcing Criteria

### Criteria Model

Criteria filter and rank candidate locations. They execute **in order** within a strategy, building up a refined and scored list.

Each criterion returns a `float`:
- **`-1.0`** — Exclude the location (removed from candidates)
- **`0.0+`** — A score (higher = better)

Criteria are classified by tag:
- **Exclusion** — Returns `-1.0` to remove locations that fail a hard constraint
- **Sorter** — Returns a numeric score to rank locations

### Built-in Criteria Reference

(Source: `SourcingCriteriaTypeRegistry.java`)

| Type | Tag | Description | Key Params |
|------|-----|-------------|------------|
| `fc.sourcing.criterion.locationDistanceExclusion` | Exclusion | Exclude locations beyond a distance threshold | `value` (max distance), `valueUnit` (`KILOMETRES` or `MILES`) |
| `fc.sourcing.criterion.locationTypeExclusion` | Exclusion | Exclude locations of specified types | `value` (list of type strings, e.g., `["STORE"]`) |
| `fc.sourcing.criterion.locationNetworkExclusion` | Exclusion | Exclude locations not in the specified network | `value` (network ref string) |
| `fc.sourcing.criterion.inventoryAvailabilityExclusion` | Exclusion | Exclude locations with insufficient stock for all order items | *(none)* |
| `fc.sourcing.criterion.locationDistance` | Sorter | Score by distance from delivery address (closest = highest score) | *(none)* |
| `fc.sourcing.criterion.locationDistanceBanded` | Sorter | Score by distance using configurable bands | `bands` (list of `{from, to, score}`) |
| `fc.sourcing.criterion.locationDailyCapacity` | Sorter | Score by remaining daily fulfilment capacity | *(none)* |
| `fc.sourcing.criterion.networkPriority` | Sorter | Score by the location's network priority field | *(none)* |
| `fc.sourcing.criterion.inventoryAvailability` | Sorter | Score by stock quantity (more stock = higher score) | *(none)* |
| `fc.sourcing.criterion.inventoryAvailabilityBanded` | Sorter | Score by stock quantity using configurable bands | `bands` (list of `{from, to, score}`) |
| `fc.sourcing.criterion.orderValue` | Sorter | Score by order value handling capability | *(none)* |

> **Note on Parameter Names:** In `util-sourcing-2.1.0`, the JSON parsing logic uses generic keys like `"value"` and `"valueUnit"` (via `params.get("value")`). Newer versions may use descriptive names like `maxDistance`, `distanceUnit`, `excludedTypes`. Always check your specific version.

### Scoring and Normalization

(Source: `SourcingCriteriaUtils.java`, `BaseSourcingCriterion.normalize()`)

**Step 1: Raw Scoring**
Each criterion calculates a raw score for every candidate location.

**Step 2: Normalization**
Raw scores are normalized to a `0.0` – `1.0` range across all candidates for that criterion:

```
normalizedScore = (rawScore - minScore) / (maxScore - minScore)
```

If all scores are equal (`maxScore == minScore`), all locations get `1.0`.

**Step 3: Distance Inversion**
`LocationDistanceCriterion` overrides normalization — it **inverts** the score so that shorter distance = higher score:

```
normalizedScore = 1.0 - ((distance - minDistance) / (maxDistance - minDistance))
```

**Step 4: Aggregation**
After all criteria run, each location has a list of normalized scores. These are combined (typically averaged) to produce a final composite score. Locations are sorted by final score descending.

**Worked Example:**

Three locations scored by `locationDistance` (raw = km from delivery address):
| Location | Raw Distance | After Normalization | After Inversion |
|----------|-------------|--------------------|-----------------|
| Store A | 5 km | (5-5)/(50-5) = 0.0 | 1.0 - 0.0 = **1.0** |
| Store B | 20 km | (20-5)/(50-5) = 0.33 | 1.0 - 0.33 = **0.67** |
| Store C | 50 km | (50-5)/(50-5) = 1.0 | 1.0 - 1.0 = **0.0** |

Result: Store A (closest) wins with score 1.0.

### Banding Pattern

Banded criteria use configurable ranges instead of continuous scoring:

```json
{
  "name": "DistanceBands",
  "type": "fc.sourcing.criterion.locationDistanceBanded",
  "params": {
    "bands": [
      { "from": 0, "to": 10, "score": 1.0 },
      { "from": 10, "to": 50, "score": 0.5 },
      { "from": 50, "to": 100, "score": 0.1 }
    ]
  }
}
```

Locations falling outside all bands receive a score of `0.0`. The `inventoryAvailabilityBanded` criterion uses the same pattern with stock quantity thresholds.

### Exclusion Pattern

Exclusion criteria are hard filters — they return `-1.0` for locations that fail the constraint, immediately removing them from the candidate pool. Exclusions should always be placed **before** sorters in the criteria chain to reduce the scoring workload.

```json
{
  "name": "ExcludeFarLocations",
  "type": "fc.sourcing.criterion.locationDistanceExclusion",
  "params": {
    "value": 100.0,
    "valueUnit": "KILOMETRES"
  }
}
```

### Custom Criteria

To add custom scoring or filtering logic, see the **Custom Extension Guide** section below. Custom criteria extend `BaseSourcingCriterion` and are registered in the `SourcingCriteriaTypeRegistry`.

## Permutation Search

### Algorithm Overview

(Source: `SourcingUtils.findPlanForAllItems()`)

When `maxSplit > 1`, the engine performs a **permutation search** to find the optimal combination of locations:

1. **Start with split count = 1** — Try to source all items from a single location
2. **Increment split count** — If no single location works, try 2-location combinations, then 3, etc.
3. **Stop at maxSplit** — Never exceed the configured maximum
4. **Fewer splits always wins** — A 1-location plan is always preferred over a 2-location plan, regardless of scores

Within each split level, the engine:
- Takes the top-scored locations from the criteria chain
- Tests all permutations to find combinations that can collectively source all items
- Selects the combination with the highest aggregate score

### maxSplit Behavior

| maxSplit | Behavior |
|----------|----------|
| `1` (default) | Single location must have all items. No split. |
| `2` | Try 1 location first, then 2-location combos |
| `3+` | Try 1, then 2, ..., up to N. First successful split level wins. |
| Not set | Defaults to 1 |

> **Performance Note:** Permutation search is combinatorial. With 100 candidate locations and `maxSplit=3`, the search space is large. Use exclusion criteria aggressively to reduce the candidate pool before permutation.

## Settings Reference

### Sourcing Settings Keys

(Source: `SourcingConditionTypeRegistry.java`, `SourcingCriteriaTypeRegistry.java`)

| Setting Key | Scope | Purpose |
|-------------|-------|---------|
| `fc.rubix.order.sourcing.conditions` | GLOBAL | Registry of built-in sourcing condition types |
| `fc.rubix.order.sourcing.conditions.custom` | ACCOUNT / RETAILER | Registry of custom sourcing condition types |
| `fc.rubix.order.sourcing.criteria` | GLOBAL | Registry of built-in sourcing criteria types |
| `fc.rubix.order.sourcing.criteria.custom` | ACCOUNT / RETAILER | Registry of custom sourcing criteria types |

### Querying Settings

Use MCP tools to inspect current sourcing settings:

```
Tool: graphql.query
Query: { settings(context: "RETAILER", contextId: <RETAILER_ID>) { edges { node { name value } } } }
Filter: name starts with "fc.rubix.order.sourcing"
```

### Custom Schema Registration

To register a custom condition or criterion type, create a setting at the `ACCOUNT` or `RETAILER` scope with the appropriate key. The value is a JSON array of type definitions:

```json
{
  "name": "fc.rubix.order.sourcing.criteria.custom",
  "context": "ACCOUNT",
  "contextId": 1,
  "value": "[{\"name\":\"Custom Carrier Preference\",\"type\":\"ext.sourcing.criterion.preferredCarrier\",\"class\":\"com.example.PreferredCarrierCriterion\"}]"
}
```

## Workflow Integration

### Workflow Rules

| Rule | Class | Purpose |
|------|-------|---------|
| `CreateFulfilmentWithSourcingProfile` | `com.fluentcommerce.rule.sourcing.CreateFulfilmentWithSourcingProfile` | Main rule — loads profile, runs evaluation, creates fulfilment |
| `CreatePartialFulfilmentWithSourcingProfile` | `com.fluentcommerce.rule.sourcing.CreatePartialFulfilmentWithSourcingProfile` | Handles partial sourcing from FALLBACK strategies |
| `CreateRejectedFulfilment` | `com.fluentcommerce.rule.sourcing.CreateRejectedFulfilment` | Creates a rejected fulfilment when no strategy can source |

### Typical Workflow Pattern

```mermaid
sequenceDiagram
    participant WF as Order Workflow
    participant Rule as CreateFulfilmentWithSourcingProfile
    participant SP as SourcingProfile
    participant Ctx as SourcingContext
    participant Loc as Location Candidates

    WF->>Rule: Event: SourceOrder (status: BOOKED)
    Rule->>SP: Load profile by ref from rule props
    Rule->>Ctx: Build SourcingContext (order, items, fulfilmentChoice)
    Rule->>Loc: Query candidate locations from network
    SP->>Ctx: Evaluate strategies (priority order)
    Note over Ctx: For each strategy:<br/>1. Check all conditions<br/>2. Run criteria chain<br/>3. Score & rank locations
    Ctx-->>Rule: FulfilmentPlan (locations + items)
    alt Plan found
        Rule->>WF: Create FULFILMENT entity
    else No plan
        Rule->>WF: Trigger CreateRejectedFulfilment
    end
```

### Rule Configuration

The sourcing rule is configured in a workflow ruleset with the profile ref as a property:

```json
{
  "name": "RULESET::SOURCE_ORDER",
  "description": "Source the order using the responsive sourcing profile",
  "type": "ORDER",
  "eventType": "NORMAL",
  "rules": [
    {
      "name": "{{fluent.rule.sourcing.CreateFulfilmentWithSourcingProfile}}",
      "props": {
        "sourcingProfileRef": "SOURCING_PROFILE_HD",
        "eventName": "FulfilmentCreated"
      }
    }
  ],
  "triggers": [
    {
      "status": "BOOKED"
    }
  ],
  "userActions": []
}
```

User action note (strict contract):
- This sourcing ruleset is backend-driven and typically keeps `userActions: []`.
- If you add user actions, set explicit `eventName` values and keep `context`/`attributes` to documented fields only.
- Validate action visibility with `workflow.transitions` using ALL THREE mandatory params (`module` specific name — `"all"` returns NOTHING, `flexType`, `flexVersion`) and cross-check `/fluent-workflow-builder` + `/fluent-transition-api`.

## GraphQL Operations

### Query Profile with Full Strategy Tree

```graphql
query GetSourcingProfile($ref: String!) {
  sourcingProfile(ref: $ref) {
    id
    ref
    status
    catalogueRef
    virtualCatalogueRef
    networkRef
    maxSplit
    sourcingStrategies {
      edges {
        node {
          id
          ref
          priority
          status
          type
          sourcingConditions {
            edges {
              node {
                id
                name
                type
                params
              }
            }
          }
          sourcingCriteria {
            edges {
              node {
                id
                name
                type
                params
              }
            }
          }
        }
      }
    }
  }
}
```

### Create Complete Profile

```graphql
mutation CreateSourcingProfile($input: CreateSourcingProfileInput!) {
  createSourcingProfile(input: $input) {
    id
    ref
    status
  }
}
```

**Input variable structure:**
```json
{
  "input": {
    "ref": "SOURCING_PROFILE_HD",
    "retailerId": 1,
    "status": "DRAFT",
    "catalogueRef": "DEFAULT:1",
    "networkRef": "HD_NETWORK",
    "maxSplit": 2,
    "sourcingStrategies": [
      {
        "ref": "STRATEGY_HD_STANDARD",
        "priority": 10,
        "status": "ACTIVE",
        "type": "PRIMARY",
        "sourcingConditions": [
          {
            "name": "IsHD",
            "type": "fc.sourcing.condition.path",
            "params": "{\"path\":\"fulfilmentChoice.type\",\"operator\":\"equals\",\"value\":\"HD\"}"
          }
        ],
        "sourcingCriteria": [
          {
            "name": "HasStock",
            "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion",
            "params": "{}"
          },
          {
            "name": "ByDistance",
            "type": "fc.sourcing.criterion.locationDistance",
            "params": "{}"
          }
        ]
      }
    ]
  }
}
```

> **Note:** The `params` field in GraphQL mutations is a **JSON string**, not a JSON object. Stringify the params before sending.

### Update & Activate

```graphql
mutation UpdateSourcingProfile($input: UpdateSourcingProfileInput!) {
  updateSourcingProfile(input: $input) {
    id
    ref
    status
  }
}
```

To activate a profile:
```json
{
  "input": {
    "id": "<PROFILE_ID>",
    "status": "ACTIVE"
  }
}
```

### Required Permissions

| Operation | Permission |
|-----------|-----------|
| Query profiles | `SOURCING_PROFILE:READ` |
| Create profile | `SOURCING_PROFILE:CREATE` |
| Update profile | `SOURCING_PROFILE:UPDATE` |
| Delete profile | `SOURCING_PROFILE:DELETE` |

## Common Use Cases

### 1. Region-Specific Sourcing

Different strategies for different delivery regions:

```json
{
  "sourcingStrategies": [
    {
      "ref": "METRO_EXPRESS",
      "priority": 10,
      "type": "PRIMARY",
      "sourcingConditions": [
        {
          "name": "MetroZone",
          "type": "fc.sourcing.condition.path",
          "params": {
            "path": "fulfilmentChoice.deliveryAddress.attributes[?(@.name=='zone')].value",
            "operator": "equals",
            "value": "METRO"
          }
        }
      ],
      "sourcingCriteria": [
        { "name": "Within10km", "type": "fc.sourcing.criterion.locationDistanceExclusion", "params": { "value": 10.0, "valueUnit": "KILOMETRES" } },
        { "name": "ByCapacity", "type": "fc.sourcing.criterion.locationDailyCapacity" }
      ]
    },
    {
      "ref": "REGIONAL_STANDARD",
      "priority": 20,
      "type": "PRIMARY",
      "sourcingConditions": [],
      "sourcingCriteria": [
        { "name": "HasStock", "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion" },
        { "name": "ByDistance", "type": "fc.sourcing.criterion.locationDistance" }
      ]
    }
  ]
}
```

### 2. Ship-from-Store Priority

Prefer stores over warehouses when stock is available:

```json
{
  "sourcingCriteria": [
    { "name": "HasStock", "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion" },
    { "name": "ExcludeWarehouses", "type": "fc.sourcing.criterion.locationTypeExclusion", "params": { "value": ["WAREHOUSE"] } },
    { "name": "ByDistance", "type": "fc.sourcing.criterion.locationDistance" }
  ]
}
```

Note: This excludes warehouses entirely. For a softer approach, use `networkPriority` with stores in a higher-priority network.

### 3. Seasonal Capacity Management

Use banded criteria to prefer locations with high remaining capacity during peak season:

```json
{
  "sourcingCriteria": [
    { "name": "HasStock", "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion" },
    {
      "name": "CapacityBands",
      "type": "fc.sourcing.criterion.inventoryAvailabilityBanded",
      "params": {
        "bands": [
          { "from": 100, "to": 999999, "score": 1.0 },
          { "from": 50, "to": 99, "score": 0.7 },
          { "from": 10, "to": 49, "score": 0.3 },
          { "from": 0, "to": 9, "score": 0.0 }
        ]
      }
    },
    { "name": "ByDistance", "type": "fc.sourcing.criterion.locationDistance" }
  ]
}
```

### 4. Click-and-Collect (Same Store)

For CC orders, source from the selected pickup location:

```json
{
  "sourcingStrategies": [
    {
      "ref": "CC_PICKUP",
      "priority": 10,
      "type": "PRIMARY",
      "sourcingConditions": [
        {
          "name": "IsCC",
          "type": "fc.sourcing.condition.path",
          "params": { "path": "fulfilmentChoice.type", "operator": "equals", "value": "CC" }
        }
      ],
      "sourcingCriteria": [
        { "name": "HasStock", "type": "fc.sourcing.criterion.inventoryAvailabilityExclusion" },
        {
          "name": "PickupStoreOnly",
          "type": "fc.sourcing.criterion.locationNetworkExclusion",
          "params": { "value": "CC_NETWORK" }
        }
      ]
    }
  ]
}
```

## Debugging and Troubleshooting

### Common Failures

| Symptom | Probable Cause | Diagnosis |
|---------|----------------|-----------|
| All strategies skipped | Conditions don't match context | Check operator case (must be lowercase), verify JSON path resolves, check `conditionScope` for array paths |
| All locations excluded | Exclusion criteria too aggressive | Check `locationDistanceExclusion` threshold, verify inventory data exists, check location types |
| Wrong location selected | Scoring/normalization issue | Review criteria order, check if distance inversion is being applied correctly |
| `ClassNotFoundException` | Custom type not registered | Verify setting `fc.rubix.order.sourcing.criteria.custom` exists at correct scope |
| Parameter ignored | Wrong JSON key name | Check if source expects `"value"` vs descriptive name for your version |
| Profile not loaded | Wrong ref in rule props | Verify `sourcingProfileRef` in workflow ruleset matches profile `ref` exactly |
| Partial fulfilment not created | No FALLBACK strategy | Add a FALLBACK strategy for split scenarios |

### Diagnosis Patterns

**1. Verify profile is active and loadable:**
```
Tool: graphql.query
Query: { sourcingProfile(ref: "SOURCING_PROFILE_HD") { id ref status maxSplit } }
```

**2. Check strategy conditions match runtime context:**
```
Tool: event.flowInspect
entityType: ORDER
entityId: <ORDER_ID>
eventName: SourceOrder
compact: false
```

Review the flow inspection output for condition evaluation results.

**3. Verify inventory data exists for candidate locations:**
```
Tool: graphql.query
Query: {
  inventoryPositions(
    catalogue: { ref: "DEFAULT:1" }
    productRef: "<PRODUCT_REF>"
  ) {
    edges { node { locationRef quantity status } }
  }
}
```

**4. Check settings for custom type registration:**
```
Tool: graphql.query
Query: {
  settings(
    context: "ACCOUNT"
    contextId: 1
    name: "fc.rubix.order.sourcing.criteria.custom"
  ) { edges { node { name value } } }
}
```

## Key Java Classes

(Source: decompiled `util-sourcing-2.1.0.jar`)

| Class | Package | Purpose |
|-------|---------|---------|
| `SourcingUtils` | `com.fluentcommerce.util.sourcing` | Main orchestrator — `findPlanBasedOnStrategies()`, `findPlanForAllItems()` |
| `SourcingContextUtils` | `com.fluentcommerce.util.sourcing` | Builds and manages the `SourcingContext` runtime object |
| `SourcingConditionUtils` | `com.fluentcommerce.util.sourcing.condition` | Evaluates conditions against context using operator registry |
| `SourcingCriteriaUtils` | `com.fluentcommerce.util.sourcing.criteria` | Executes criteria chain, normalization, aggregation |
| `SourcingConditionTypeRegistry` | `com.fluentcommerce.util.sourcing.condition` | Maps condition type strings to implementing classes |
| `SourcingCriteriaTypeRegistry` | `com.fluentcommerce.util.sourcing.criteria` | Maps criterion type strings to implementing classes |
| `LocationUtils` | `com.fluentcommerce.util.sourcing` | Haversine distance calculation, 10-minute location cache |
| `OrderUtils` | `com.fluentcommerce.util.sourcing` | Order item extraction and quantity calculation |

### Extension Points

| Interface / Base Class | Implement For |
|----------------------|---------------|
| `SourcingCondition` | Custom condition type (boolean gate) |
| `BaseSourcingCriterion` | Custom criterion type (scorer/filter) |
| `SourcingConditionOperator` | Custom comparison operator |

## Custom Extension Guide

### Building a Custom Condition

**Step 1: Implement `SourcingCondition` interface**

```java
package com.example.sourcing.condition;

import com.fluentcommerce.util.sourcing.condition.SourcingCondition;
import com.fluentcommerce.util.sourcing.model.SourcingContext;
import com.fasterxml.jackson.databind.JsonNode;

public class GeofenceCondition implements SourcingCondition {

    private double minLat;
    private double maxLat;
    private double minLng;
    private double maxLng;

    @Override
    public void init(JsonNode params) {
        this.minLat = params.get("minLat").asDouble();
        this.maxLat = params.get("maxLat").asDouble();
        this.minLng = params.get("minLng").asDouble();
        this.maxLng = params.get("maxLng").asDouble();
    }

    @Override
    public boolean evaluate(SourcingContext context) {
        double lat = context.getFulfilmentChoice()
            .getDeliveryAddress().getLatitude();
        double lng = context.getFulfilmentChoice()
            .getDeliveryAddress().getLongitude();

        return lat >= minLat && lat <= maxLat
            && lng >= minLng && lng <= maxLng;
    }
}
```

**Step 2: Register in `SourcingConditionTypeRegistry`**

Add to your module's initialization:
```java
SourcingConditionTypeRegistry.register(
    "ext.sourcing.condition.geofence",
    GeofenceCondition.class
);
```

**Step 3: Create the setting**

Register at ACCOUNT or RETAILER scope:
```json
{
  "name": "fc.rubix.order.sourcing.conditions.custom",
  "context": "ACCOUNT",
  "contextId": 1,
  "value": "[{\"name\":\"Geofence\",\"type\":\"ext.sourcing.condition.geofence\",\"class\":\"com.example.sourcing.condition.GeofenceCondition\"}]"
}
```

**Step 4: Use in a strategy**

```json
{
  "name": "InSydneyMetro",
  "type": "ext.sourcing.condition.geofence",
  "params": {
    "minLat": -34.0,
    "maxLat": -33.5,
    "minLng": 150.8,
    "maxLng": 151.4
  }
}
```

### Building a Custom Criterion

**Step 1: Extend `BaseSourcingCriterion`**

```java
package com.example.sourcing.criterion;

import com.fluentcommerce.util.sourcing.criterion.BaseSourcingCriterion;
import com.fluentcommerce.util.sourcing.criterion.annotation.SourcingCriterionInfo;
import com.fluentcommerce.util.sourcing.criterion.annotation.SourcingCriterionParam;
import com.fluentcommerce.util.sourcing.criterion.annotation.SourcingCriterionParamSelectComponentOption;
import com.fluentcommerce.util.sourcing.model.CriterionContext;
import com.fasterxml.jackson.databind.JsonNode;

@SourcingCriterionInfo(
    name = "Preferred Carrier",
    type = "ext.sourcing.criterion.preferredCarrier",
    tags = {"Sorter"},
    description = "Boost locations that support the customer's preferred carrier"
)
public class PreferredCarrierCriterion extends BaseSourcingCriterion {

    @SourcingCriterionParam(
        name = "carrierAttributeName",
        component = "input",
        description = "Name of the location attribute holding supported carriers"
    )
    private String carrierAttributeName;

    @SourcingCriterionParam(
        name = "boostFactor",
        component = "select",
        description = "Score multiplier when carrier matches"
    )
    @SourcingCriterionParamSelectComponentOption(name = "High", value = "2.0")
    @SourcingCriterionParamSelectComponentOption(name = "Medium", value = "1.5")
    @SourcingCriterionParamSelectComponentOption(name = "Low", value = "1.2")
    private double boostFactor;

    @Override
    public void parseParams(JsonNode params) {
        this.carrierAttributeName = params.has("carrierAttributeName")
            ? params.get("carrierAttributeName").asText()
            : "supportedCarriers";
        this.boostFactor = params.has("boostFactor")
            ? params.get("boostFactor").asDouble()
            : 1.5;
    }

    @Override
    public float apply(CriterionContext context) {
        // Get customer's preferred carrier from order attributes
        String preferredCarrier = getOrderAttribute(
            context, "preferredCarrier");

        if (preferredCarrier == null) {
            return 1.0f; // No preference — neutral score
        }

        // Check if location supports this carrier
        String supported = getLocationAttribute(
            context, carrierAttributeName);

        if (supported != null && supported.contains(preferredCarrier)) {
            return (float) boostFactor; // Boosted score
        }

        return 1.0f; // Base score
    }
}
```

**Annotation Reference:**

| Annotation | Purpose |
|-----------|---------|
| `@SourcingCriterionInfo` | Declares the criterion name, type key, tags (Exclusion/Sorter), and description |
| `@SourcingCriterionParam` | Declares a configurable parameter with name, UI component type, and description |
| `@SourcingCriterionParamSelectComponentOption` | For `component="select"` params — defines dropdown options with name/value pairs |

**Step 2: Register in `SourcingCriteriaTypeRegistry`**

```java
SourcingCriteriaTypeRegistry.register(
    "ext.sourcing.criterion.preferredCarrier",
    PreferredCarrierCriterion.class
);
```

**Step 3: Create the setting**

```json
{
  "name": "fc.rubix.order.sourcing.criteria.custom",
  "context": "ACCOUNT",
  "contextId": 1,
  "value": "[{\"name\":\"Preferred Carrier\",\"type\":\"ext.sourcing.criterion.preferredCarrier\",\"class\":\"com.example.sourcing.criterion.PreferredCarrierCriterion\"}]"
}
```

**Step 4: Use in a strategy**

```json
{
  "name": "CarrierBoost",
  "type": "ext.sourcing.criterion.preferredCarrier",
  "params": {
    "carrierAttributeName": "supportedCarriers",
    "boostFactor": 1.5
  }
}
```

### Testing Custom Extensions

1. **Unit test** — Mock `SourcingContext` and `CriterionContext`, verify scores
2. **Integration test** — Deploy to sandbox, create a test profile with the custom type, run `/fluent-e2e-test`
3. **Verify registration** — Query the custom settings to confirm type is registered:
   ```
   Tool: graphql.query
   Query: { settings(name: "fc.rubix.order.sourcing.criteria.custom", context: "ACCOUNT", contextId: 1) { edges { node { value } } } }
   ```

## Integration with Other Skills

| Task | Skill | How |
|------|-------|-----|
| Build workflow that triggers sourcing | `/fluent-workflow-builder` | Add `CreateFulfilmentWithSourcingProfile` to a ruleset |
| Scaffold custom criterion Java class | `/fluent-rule-scaffold` | Generate boilerplate extending `BaseSourcingCriterion` |
| Deploy sourcing settings | `/fluent-settings` | Create/update `fc.rubix.order.sourcing.*` keys |
| Query live profiles via MCP | `/fluent-mcp-tools` | Use `graphql.query` with the GraphQL patterns above |
| Trace sourcing event execution | `/fluent-trace` | Inspect `SourceOrder` event flow and rule outputs |
| Configure locations/networks | `/fluent-retailer-config` | Set up the location network referenced by profiles |
| Create test orders for sourcing | `/fluent-test-data` | Generate orders with specific fulfilment choices |
| Run E2E sourcing flow | `/fluent-e2e-test` | Create order → source → verify fulfilment plan |
| Analyze existing sourcing workflows | `/fluent-workflow-analyzer` | Map status graph and ruleset connections |

## Learning Capture

This is an analysis skill — follow the **Learning Capture Protocol** (see CLAUDE.md Cross-Skill Conventions). After sourcing analysis, when the user confirms or corrects findings about sourcing profile configuration, strategy behavior, or scoring quirks, write the confirmed learning to the `implementation-learnings.md` auto-memory file under the relevant account heading.

## Tips

- **Only one ACTIVE version** of a profile ref can exist. Activating a new version deactivates the old one.
- **Conditions use AND logic** — ALL conditions in a strategy must pass. For OR logic, create separate strategies.
- **Exclusion score is `-1.0`** — Any criterion returning `-1.0` permanently removes that location from consideration for the current strategy.
- **Normalization scope is per-criterion** — Each criterion normalizes independently across all remaining candidates, not across all criteria.
- **Location cache TTL is 10 minutes** — `LocationUtils` caches location data. Changes to location attributes may take up to 10 minutes to be reflected in sourcing.
- **maxSplit increases computation** — Permutation search is combinatorial. Use exclusion criteria to reduce candidate count before permutation.
- **Operators are lowercase** — `equals`, not `EQUALS` or `Equals`. This is the most common configuration error.
- **params is a JSON string in mutations** — When creating profiles via GraphQL, stringify the params object.
- **Order criteria carefully** — Exclusions first (reduce pool), then sorters (rank remaining). This improves both performance and correctness.
- **networkRef on profile is the default** — Individual strategies can override with `locationNetworkExclusion` criterion.
- **`virtualCatalogueRef` overrides `catalogueRef`** — If both are set, the virtual catalogue is used for inventory lookups.
- **Use `exists` operator for optional attributes** — Check if an attribute exists before comparing its value in a separate condition.
- **Test with `/fluent-trace`** — After configuring a profile, send a test order event and use flow inspection to verify the sourcing decision path.
