Consignor and 3PL

FG.AI WMS v1 ships 3PL-first: every document, every inventory record, every movement carries a consignor_id that identifies the owner of the goods. Direct-tenant operators (a retailer running their own warehouse) are supported as the degenerate case (one consignor per account). The architecture lives in WMS ADR-0041; the v1 priority decision lives in WMS ADR-0044.

This page covers the contract surface for upstream integrators feeding consignor-aware data into FG.AI WMS.

The model

DotID account  ──►  3PL operator      (top-level tenant)
      │
      ├──►  Consignor A     (sub-tenant — owner of goods)
      ├──►  Consignor B     (sub-tenant — owner of goods)
      └──►  Consignor C     (sub-tenant — owner of goods)

A direct-tenant retailer is the same model with exactly one consignor whose ID equals the account self-reference. Tooling treats both shapes uniformly.

Master data — consignor registration

Before any consignor-scoped document or inventory record is accepted, the consignor itself must be registered.

POST /wms-ingest/v1/master/consignors?mode=upsert

{
  "consignor": {
    "source_id": "acme-corp",
    "display_name": "Acme Corporation",
    "billing_config": {
      "storage_fee_per_unit_day": "0.05",
      "transaction_fee_per_movement": "0.02",
      "currency": "USD"
    },
    "contact": {
      "email": "warehouse-ops@acme.example",
      "phone": "+1-555-0100"
    },
    "active_from": "2026-06-01T00:00:00Z",
    "active_until": null,
    "notes": "Self-service consignor; ships ambient electronics."
  }
}

billing_config shape is informational in v1; per-consignor billing flows through the v1.7 finance-service per WMS ADR-0037. Storing the configuration in v1 keeps the migration story clean.

The account-self-as-consignor degenerate case is registered automatically at account creation; you do not need to register it explicitly.

consignor_id on every document

Every document endpoint accepts a consignor_id field (FRN URN, or the consignor source_id if the upstream prefers natural keys — FG.AI resolves to the FRN internally per the rules in Identity mapping):

POST /wms-ingest/v1/documents/sales-orders?mode=upsert

{
  "sales_order": {
    "source_id": "SO-2026-00042",
    "consignor_id": "acme-corp",          ◄── required
    "ship_to_address_ref": "...",
    "lines": [
      { "sku_ref": "ACME-WIDGET-RED", "qty": 4, "uom_ref": "EA" }
    ],
    ...
  }
}

If consignor_id is missing on a document body, the request is rejected with a schema validation error (MISSING_REQUIRED_CONSIGNOR_ID) — not quarantined. This is a load-bearing distinction: a missing consignor cannot be inferred or auto-resolved; the upstream must send it.

If the referenced consignor does not exist in master data, the document is quarantined (UNRESOLVED_CONSIGNOR_REFERENCE) and surfaces in the supervisor queue. See Normalization and quarantine.

Authorization scoping

The credentials presented in a request authorize the caller to a specific set of consignors:

  • A 3PL operator caller can carry write authority over many consignors and read authority over those plus aggregate views.

  • A consignor admin caller can only write/read documents scoped to that consignor; cross-consignor reads return 403.

  • A direct-tenant operator is exactly the consignor admin shape with one consignor.

The PDP filters every read by the caller’s authorized consignor set. There is no “ALL” wildcard for arbitrary callers; even 3PL operators are scoped to consignors explicitly granted to them in DotID.

Inventory scoping

Inventory snapshots and movements carry consignor_id per row:

POST /wms-ingest/v1/inventory/snapshot?mode=bulk&scope=FULL

{
  "snapshot": {
    "warehouse_ref": "wh-tokyo-01",
    "items": [
      { "consignor_id": "acme-corp",  "sku_ref": "ACME-WIDGET-RED", "location_ref": "BIN-A1",  "qty": 120 },
      { "consignor_id": "acme-corp",  "sku_ref": "ACME-WIDGET-RED", "location_ref": "BIN-A2",  "qty":  40 },
      { "consignor_id": "beta-mfg",   "sku_ref": "BETA-GIZMO-V2",   "location_ref": "BIN-C7",  "qty":  16 }
    ]
  }
}

Two consignors can share the same SKU code (the SKU is also scoped by consignor_id). The full inventory key in v1 is (account, consignor, sku, location, lot_id?).

Segregated mode (v1 default)

v1 ships in segregated mode — every consignor’s goods are confined to specific zones or bins. The Location master defines this:

POST /wms-ingest/v1/master/locations?mode=upsert

{
  "location": {
    "source_id": "ZONE-ACME-MAIN",
    "kind": "ZONE",
    "parent_ref": "wh-tokyo-01",
    "consignor_assignment": "acme-corp",   ◄── single-consignor zone
    ...
  }
}

A location with consignor_assignment set only accepts inventory for that consignor; putaway and pick Rules enforce the assignment.

Commingled mode (FIFO across consignor ownership in a single bin) is deferred to v1.8 per WMS ADR-0044 §4. Until then, attempts to write inventory of one consignor into a bin assigned to another are quarantined (CONSIGNOR_ASSIGNMENT_VIOLATION).

Cross-consignor transfers

A TransferOrder may move goods between locations within the same consignor (intra-consignor — standard saga path) or across consignor ownership (cross-consignor — requires elevated permission and a 3PL admin role per DotID consignor_admin scope rules). The contract surface is the same; the chain service distinguishes the two cases at runtime.

POST /wms-ingest/v1/documents/transfer-orders?mode=upsert

{
  "transfer_order": {
    "source_id": "XFR-2026-00003",
    "consignor_id": "acme-corp",             ◄── source consignor
    "destination_consignor_id": "acme-corp", ◄── intra-consignor transfer
    "from_location_ref": "BIN-A1",
    "to_location_ref": "BIN-B5",
    ...
  }
}

For cross-consignor, destination_consignor_id differs from consignor_id. v1.8 will tighten the cross-consignor permission model.

Webhooks carry consignor_id

The three webhook event kinds all carry the relevant consignor identifier in their payloads:

  • document.state-changedpayload.consignor_id

  • inventory.adjusted — per-row consignor_id

  • master.normalization-conflictpayload.consignor_id when the conflicting record is consignor-scoped (consignor entity itself, scoped SKU, etc.)

Subscribers should filter by consignor_id when projecting webhook events into their own per-consignor stores.

Anti-patterns

Sending documents before the consignor is registered. Results in quarantine. Always upsert /master/consignors first.

Cross-consignor inventory writes via a single batch. Each row is permission-checked independently; a row with a consignor you are not authorized for fails individually. Group writes by consignor when the upstream is a consignor-admin caller; group freely when the upstream is a 3PL operator caller.

Treating consignor_id as optional in v1 because behavior activated late. The v1 schema requires consignor_id on every document and inventory row from launch per ADR-0044 §2 Phase 1. There is no quiet-default mode.