Skip to content

Design: Connect Item.Supply to Business Affiliate References

This is a lightweight TLD proposal organized under the early-design-review headings — Goal, Scope, Functional Modules & Endpoints, and Scenarios — followed by the decision traceability, testing strategy, and references the implementing team needs.


Linear ticket: PDEV-731 — Connect Item.Supply to Business Affiliate References (parent PDEV-182, project “Reference / Supplier Management”).

ItemSupply records (and the primarySupply / secondarySupply snapshots on Item) currently link to a supplier through an ad-hoc pair of fields — supplierEId: EntityId? (holding a BusinessAffiliate eId) plus a denormalized supplier: String. The goal is to replace that ad-hoc link with a canonical reference value object, SupplierReference.Value, that targets the specific BusinessRole with role VENDOR (per DQ-001), following the established cross-Universe reference pattern (eId / rId / cached name, floating-or-pinned; per DQ-002), and to make the Item module react correctly when a supplier is removed.

  1. Add primary/secondary supplies on item creation — (a) with a pre-existing supplier; (b) creating the supplier on-the-fly.
  2. Edit primary/secondary supplies for an existing item — (a) assign a pre-existing supply; (b) create a new supply with a pre-existing supplier; (c) create a new supply, supplier on-the-fly.
  3. List all supplies associated with an item.
  4. Remove a supply — (a) when it is not primary/secondary; (b) when it is primary/secondary (guarded).
  5. Remove a supplier — emit notifications and have the Item module react by marking affected supplies stale.
  • API / backend services layer only (operations); front-end is tracked separately (e.g. PDEV-764).
  • Only the VENDOR BusinessRole is in scope; Customer/Carrier/Operator roles are not.
  • Cross-Universe rule: no foreign keys and no shared transactions across the Item and BusinessAffiliate universes; resolution always goes through the owning service interface.
  • Follow Arda Kotlin conventions (Result/AppError, Exposed, smart constructors) and the bitemporal table conventions.
  • A CHANGELOG entry is required per PR, per each repo’s model.
  • Assumption: the existing in-code wiring is the baseline being redesigned (not greenfield) — see Key Insight.

The ticket premise (“supplies don’t refer to real suppliers — supplier entity not live”) is out of date. operations already wires Item↔BusinessAffiliate: supplierEId, ItemVendorResolver.resolveVendor, the STRICT/LAX/PROPAGATE qualifier modes, and VENDOR name-change propagation all exist. So this is a redesign, and the one genuinely missing behavior is UC 5 — the reaction to supplier removal, which today is explicitly ignored.

Two derived design moves shape the rest of the proposal:

  • Stale, don’t delete (DQ-003): on supplier removal, mark the supply’s supplier reference stale (retired = true, reference pinned to the supplier’s tombstone), exactly mirroring how a KanbanCard’s ItemReference reacts when its Item is deleted (PDEV-808). Supply data is preserved so pending workflows already committed to those values still resolve.
  • Mutation semantics (DQ-011/DQ-012): supply resolution recognizes three qualifiers — STRICT (the referenced VENDOR role must resolve to a live supplier), PROPAGATE (create-on-the-fly: find or create the BusinessAffiliate
    • VENDOR role, then link), and LAX (leave the supply unlinked — name-only, no name-lookup upsert). DQ-011 removed the LAX name-lookup upsert, not the LAX value itself (the qualifier is shared with item-level bulk mutations). The endpoint default is PROPAGATE. A new supply may never start dead — resolving against a retired supplier is rejected: STRICT/LAX naturally (the retired role/affiliate is filtered from active bitemporal queries) and PROPAGATE explicitly (it must not resurrect a retired role).
#DecisionChosen Option
DQ-001Which entity does a supply reference — BA or BusinessRole(VENDOR)?BusinessRole(VENDOR)
DQ-002Reference form — canonical Reference value object vs plain UUID+nameCanonical SupplierReference.Value
DQ-003Effect of supplier removal on an ItemSupplyStale marker (PDEV-808 pattern), no delete
DQ-004Extract a dedicated ItemSupplyService?Yes — extract from inner SupplyService
DQ-005Adopt canonical reference-data URL routes?Yes
DQ-006How is a child BusinessRole resolved across the Universe boundary?Carry affiliateEId in the reference
DQ-007Event source for supplier removal (cascade vs BA-delete subscription)Deferred — verify cascade in code (T-08)
DQ-008Migration/backfill of existing supplierEId (BA eId → VENDOR role)Lazy re-link — no backfill
DQ-009Backward compatibility of legacy routes + api-test path migrationKeep legacy routes as aliases
DQ-010Floating-vs-pinned semantics on retirementMirror Kanban (floating; pin on retire)
DQ-011Supply mutation qualifier set for ItemSupply creationSTRICT / PROPAGATE (create-on-the-fly) / LAX (unlinked); drop LAX name-lookup upsert
DQ-012Creating a supply that points to a retired supplierDisallowed — bitemporal filter (STRICT/LAX) + explicit PROPAGATE reject
DQ-013Representing an unlinked/legacy vendor nameSuperseded by DQ-014
DQ-014Eliminating the redundant supplier nameSupplier descriptor: name on the reference (required); eId/affiliateEId nullable; drop ItemSupply.supplier

Full rationale in Decision Log.


Where the work touches — the blast radius across repositories, modules, and resources.

RepositoryTouchedBlast radius
operationsItem module (business/endpoints/service/persistence) + BusinessAffiliate module (service possibly extended)Reference value object, dedicated service, Flyway migration, canonical routes, unit + integration tests
api-testItem + BusinessAffiliate Bruno collectionsNew supplier-linking and stale-on-removal scenarios
documentationcurrent-system, domain/information-model, url-naming.mdDesign page, model alignment, reference-data route convention
  • Resources: a Flyway migration on the Item database (the ITEM_TABLE supply components and the ITEM_SUPPLY_TABLE). No new AWS infrastructure and no new libraries.
FilePackage/PathPurpose
SupplierReference.ktreference/item/domainCanonical supplier reference value object
SupplierReferenceComponent.ktreference/item/domain/persistenceFlattened SUPPLIER_REFERENCE_* columns
ItemSupplyService.ktreference/item/serviceExtracted dedicated supply service
VNNN__supplier_reference.sqlresources/item/database/migrationsReplace supplier columns; add reference + retired + provenance
FileChange Description
ItemSupply.ktReplace supplierEId+supplier with supplier: SupplierReference.Value
ItemSupplyReference.ktEmbed SupplierReference in primary/secondary snapshots
Item.ktAdjust supply snapshot fields / validation
ItemService.ktRemove inner SupplyService; delegate to ItemSupplyService
ItemVendorResolver.ktRetarget resolution to VENDOR role; produce SupplierReference
ItemSupplyPersistence.kt / ItemPersistence.ktNew reference columns; drop legacy supplier columns
ItemEndpoint.kt / Module.ktCanonical reference-data routes (+ legacy aliases per DQ-009)
BusinessAffiliateService.ktRole-by-eId resolution and/or BA-delete cascade events (per DQ-006/007)
  • Front-end design and implementation (separate tickets, e.g. PDEV-764).
  • Customer/Carrier/Operator roles — only VENDOR is in scope.
  • Bulk supplier reconciliation tooling beyond the one-time migration backfill.

Classes are grouped by the functional decomposition hierarchy {domain}/{module}/{layer} — domain reference-data, modules item and business-affiliate, and the layers business, endpoints, service, persistence. Solid composition arrows are within a module; dashed arrows crossing the two modules are cross-Universe soft references (no FK, resolution via the owning service interface).

PlantUML diagram

SupplierReference.Value (in the item module’s business layer) takes the ItemReference.Value shape (rId, name, retired, provenance) but — per DQ-014 — is a supplier descriptor, not a strict EntityPayload reference: its name is required (the single home for the vendor name), while its eId (the VENDOR BusinessRole, per DQ-001) and affiliateEId (parent BA, per DQ-006) are nullable — null means unlinked/legacy (name-only), non-null means linked and resolvable via BusinessRoleUniverse(affiliateEId). This relaxes DQ-002 transitionally: the nullable identity is a migration-window state that carries legacy unlinked supplies through lazy re-link (DQ-008); once no unlinked supplies remain, eId/affiliateEId are tightened back to non-null and the reference regains its canonical shape (see DQ-014 future-convergence follow-up, PDEV-860). Per DQ-004, ItemSupplyService is extracted into the item module’s service layer as a peer collaborator of ItemService.

  • Package: cards.arda.operations.reference.item.domain (new)
  • Responsibility: Cross-Universe supplier descriptor — always carries the vendor name; optionally links to the VENDOR BusinessRole that supplies it.
  • Key fields: name: String (required — single source of the vendor name), eId: EntityId? (VENDOR role eId — null = unlinked), affiliateEId: UUID? (parent BA eId — null = unlinked), rId (pinned record id, null when floating), retired: Boolean = false, provenance: Provenance.Value?. Not an EntityPayload (identity is lazy).
  • Design decision: DQ-001, DQ-002 (relaxed), DQ-006, DQ-010, DQ-014.
  • Package: cards.arda.operations.reference.item.business
  • Responsibility: A way an item can be acquired from a supplier.
  • Change (DQ-014): drop the redundant supplier: String; the required supplier: SupplierReference.Value descriptor carries the vendor name and the optional VENDOR-role link (possibly retired).
  • Design decision: DQ-001, DQ-002, DQ-014.
  • Package: cards.arda.operations.reference.item.service (new file)
  • Responsibility: Owns the ItemSupply child universe; create/update/remove/ list supplies; resolve supplier references through BusinessAffiliateService; subscribe to BusinessAffiliate/BusinessRole events and apply name-change propagation and stale-marking.
  • Key methods: supplies(parentEId, time), createItemSupply(...), updateItemSupply(...), removeItemSupply(...), resolveSupplier(...), handleVendorNotification(...).
  • Design decision: DQ-004.

BusinessAffiliateService (possibly extended)

Section titled “BusinessAffiliateService (possibly extended)”
  • Package: cards.arda.operations.reference.businessaffiliates.service
  • Possible additions (pending DQ-006/DQ-007): a “resolve VENDOR role by role eId” capability and/or BusinessAffiliate-deletion notifications that cascade to VENDOR role removals.

Canonical routes follow the Arda structure {version}/{domain}/{module}/{service}/{endpoint}five explicit, all-singular segments; segments stay explicit even when literals repeat (DQ-005). This project introduces the reference-data domain segment — which url-naming.md does not yet define; T-17 adds the convention. Per the service split (DQ-004), ItemSupply is exposed on its own endpoint (item-supply/supply) rather than under the item endpoint. Legacy routes are retained as aliases per DQ-009.

Scope: these canonical routes are added in the Kotlin/Ktor routing only for now — no Helm chart route/ingress changes and no CloudFormation/CDK changes.

Canonical paths (module/service/endpoint all explicit):

Resource{module}{service}{endpoint}Path
Itemitemitemitem/v1/reference-data/item/item/item/{id}/…
ItemSupplyitemitem-supplysupply/v1/reference-data/item/item-supply/supply/{item-eId}/…
BusinessAffiliatebusiness-affiliatebusiness-affiliatebusiness-affiliate/v1/reference-data/business-affiliate/business-affiliate/business-affiliate/{id}/…

Supply routes (on the dedicated item-supply/supply endpoint), keyed by the parent {item-eId}:

  • Add / list: POST /v1/reference-data/item/item-supply/supply/{item-eId}/add · GET .../supply/{item-eId}/list
  • Update / remove: PUT .../supply/{item-eId}/{supply-id}/update · DELETE .../supply/{item-eId}/{supply-id}/delete (guarded if primary/secondary)
  • Each canonical route shares the legacy handler; the legacy paths (/v1/item/item/..., /v1/business-affiliate/business-affiliate/...) remain as aliases (DQ-009).
  • Authentication: Bearer token + X-Tenant-Id (unchanged).
  • Query params (DQ-011): mutation-mode ∈ { STRICT, PROPAGATE (create-on-the-fly), LAX (unlinked) }, default PROPAGATE; effectiveAsOf.
  • Error responses:
    • 400 — invalid SupplierReference: STRICT with an unresolvable role, or a resolved-but-retired supplier (DQ-012).
    • 404 — item or supply not found.
    • 409 — remove of a supply still referenced as primary/secondary.

Sequence walkthroughs across the actors (client, Item module, BusinessAffiliate module) that validate the proposal.

Scenario: Add a supply with a pre-existing supplier (UC 1a / 2a)

Section titled “Scenario: Add a supply with a pre-existing supplier (UC 1a / 2a)”

The caller supplies a SupplierReference carrying the VENDOR role eId and its parent affiliateEId (required per DQ-006, since the role is a child entity). ItemSupplyService resolves it through BusinessAffiliateServiceBusinessRoleUniverse(affiliateEId) — to validate it and refresh the cached name, then persists the supply.

PlantUML diagram

Error path (DQ-011, DQ-012): if the role eId does not resolve to a VENDOR role, or the resolved VENDOR role / parent BusinessAffiliate is retired, STRICT returns a validation AppError — a new supply may not start dead. Under LAX the supply is left unlinked; under PROPAGATE the supplier is created on-the-fly (next scenario), except that a retired supplier is rejected rather than resurrected.

Scenario: Add a supply creating the supplier on-the-fly (UC 1b / 2c)

Section titled “Scenario: Add a supply creating the supplier on-the-fly (UC 1b / 2c)”

In PROPAGATE (create-on-the-fly) mode with no resolvable supplier, ItemSupplyService asks BusinessAffiliateService to create the BusinessAffiliate and its VENDOR role, then links the supply.

PlantUML diagram

Scenario: Supplier removed — mark supplies stale (UC 5)

Section titled “Scenario: Supplier removed — mark supplies stale (UC 5)”

Mirrors PDEV-808. ItemSupplyService observes a VENDOR BusinessRole deletion, finds every supply referencing that role, stamps retired = true + provenance, freezes rId to the tombstone, persists the supply (no delete), and updates the parent Item’s primary/secondary references the same way.

PlantUML diagram

Resolution on read (per DQ-010): a floating reference resolves via the live VENDOR role; a retired+pinned reference resolves via the supplier tombstone record so the supply still renders its last-known supplier state.


Validates the scenarios above plus the persistence and migration changes.

TestTargetValidates
Resolve existing VENDOR role (STRICT)ItemSupplyService.resolveSupplierReference validated, name cached
Create supplier on-the-flyItemSupplyService.createItemSupplyBA + VENDOR role created and linked
STRICT against unresolvable role rejectedItemSupplyService.resolveSupplierValidation AppError, no LAX fallback (DQ-011)
STRICT against retired supplier rejectedItemSupplyService.resolveSupplierValidation AppError — no new dead link (DQ-012)
Supplier removal marks staleItemSupplyService notification handlerretired=true, rId pinned, supply not deleted
Stale reference resolves to tombstonereference resolutionRead returns last-known supplier state
Remove primary/secondary supply blockedItemSupplyService.removeItemSupplyReturns conflict per existing guard
TestSetupValidates
End-to-end create+link with ContainerizedPostgresmigrated schemaPersistence of SUPPLIER_REFERENCE_* columns
Migration (lazy re-link)seed legacy supplier_eid rowsMigration applies; legacy rows read back as unlinked/floating, re-link on edit (DQ-008)
TestMethodPathExpected
Add supply with existing supplierPOST.../reference-data/item/item-supply/supply/{item-eId}/add200, reference resolved
Add supply creating supplierPOST.../reference-data/item/item-supply/supply/{item-eId}/add200, BA+role created
List suppliesGET.../reference-data/item/item-supply/supply/{item-eId}/list200, includes supplier ref
Remove primary supplyDELETE.../item/item-supply/supply/{item-eId}/{supply-id}/delete409 guard
Supplier removal stalenessDELETE BA role then GET supplysupply present, retired=true



Copyright: (c) Arda Systems 2025-2026, All rights reserved