Skip to content

0047. Create Positive User products under their SKU and address them by id

Date: 2026-10-14 Status: Accepted

Context

A shop's products have to reach a Positive User (user.com) workspace too, so that the workspace can build on its shop's catalogue. They go through the writer that already creates the contacts (ADR-0031), under the same ledger rule: Conflux creates its own record and remembers the id the target returned (ADR-0015).

A product is not a contact, and four facts about the app shape the decision.

  • A product cannot be created without a key. POST products-by-id/ requires a custom_id, and the app enforces that no two products in a workspace share one. A contact has no such requirement.
  • A taken key answers 400. Writing a custom_id another product holds answers 400 {"custom_id": ["product with this Custom ID already exists."]}, on a create and on a rename alike.
  • PUT products/{id}/ accepts a changed custom_id. Measured against a staging workspace: the product is renamed in place and the old key then answers 404.
  • Every key a product is written with becomes an attribute of every product in the workspace. A payload carrying whatever the shop holds would rewrite the workspace's product schema on the first delivery.

The workspace also holds products Conflux never created: a product feed, or products entered by hand. Nothing tells those apart from Conflux's own except the ledger.

ADR-0015 rules that an update the target answers with "not found" is reported and never recreated, because a deletion in the target is a human decision. Whether that holds for a product, which carries no personal data and whose truth is the shop, was weighed; the decision below keeps it.

Decision

The custom_id is the product's SKU, or its EAN where it has no SKU. Taken as the shop holds it, trimmed, unprefixed. The EAN is the record's own field, or the shop's custom attribute named ean where the field is empty. A product with neither is refused as an UndeliverableRecordException: the ledger row becomes undeliverable with the reason in last_error, shown with the rule's delivery records, and the source row has to change before it is tried again.

The product is addressed by the numeric id its create returned, never by its custom_id. The id goes into the ledger (outbound_deliveries.remote_id) exactly like a contact's. Update is PUT products/{id}/ and delete is DELETE products/{id}/. The create goes to products-by-id/, because that is the only endpoint that takes a custom_id.

Every update carries the current custom_id. The payload is the full one, so a SKU changed in the shop renames the remote product in place instead of leaving it under a key that no longer matches.

Conflux writes only products it created. A 400 naming custom_id as taken, on a create or on a rename, becomes an UndeliverableRecordException that names the key. Nothing is merged into the other product and nothing is retried. Two source products with the same SKU therefore end up as one delivered and one undeliverable, in the order they are delivered.

A product deleted in the workspace is not created again. An update the app answers with its own 404 is a RemoteRecordGoneException, exactly as for a contact: the ledger row becomes remote_gone and delivery of that product stops. ADR-0015 applies unchanged. "The app's own" means the same as everywhere in this writer: a JSON body carrying detail, or an empty body. An HTML error page is a proxy talking and stays an ordinary, retryable failure.

Deletion, not deactivation. The app has no inactive state for a product, so the writer declares CanDeleteRemoteRecords and not CanDeactivateRemoteRecords. The seeder gives the Positive User products direction push_out and defaults a product rule to overwrite with delete. A mass deletion is held by the volume breaker like any other withdrawal (ADR-0035), so a shop that loses its catalogue does not empty the workspace unattended.

Attributes are written only where the workspace's products already have them. UserPlatformProductSchema reads the attribute names off one product the workspace holds (the keys of the first result of GET products/ that are not model keys) and caches the answer for 300 seconds per connection and subdomain. The fixed attribute fields (sku, ean, price, original_price, currency, status) and the shop's own custom attributes are written only under a name in that list. Prices travel as the decimal a person reads ("899.00"), converted from minor units at this boundary. The workspace owner decides the product schema. conflux_source is the exception and is always written. The model's own fields (custom_id, name, description, product_url, image_url, brand_name, target_group, created_at, updated_at) are written when the record carries them. Categories and tags are never written: categories take the workspace's own ids, and the create ignores tags. A shop attribute named like a reserved key is refused, naming the key. A schema the workspace cannot answer fails the write as a retryable OutboundWriteException.

Alternatives considered

A custom_id derived from connection and source id ({connection}:{sourceId}). It would never collide with another product, which is its appeal. Rejected: it is unreadable to the people who work with the workspace, who know a product by its SKU. A workspace that already feeds products under their SKUs would also see them twice.

Addressing by custom_id (products-by-id/{custom_id}/). It would need no id in the ledger. Rejected on three counts. A SKU changed at the source would orphan the product, because the old key no longer finds it. The write would meet a product some other source created under that key and overwrite it, which is exactly what this decision rules out. And a / in a SKU cannot be routed.

Creating a product again where the workspace answers 404. A product carries no personal data and the shop still sells it, so the workspace would always mirror the catalogue. Rejected: a deletion in the workspace is a decision somebody made there, and Conflux does not overrule it — the rule ADR-0015 sets for every domain, and the one asked of User Platform for its own endpoints. It would also have needed update() to hand a new id back to the ledger.

Consequences

A product can fail for a reason only the shop owner can fix. No SKU or EAN, a duplicate SKU, or a shop attribute with a reserved name each leave a undeliverable row with the cause in last_error. These are record faults and do not count against the connection's circuit breaker (ADR-0032).

Two shop products with one SKU deliver one. Whichever is delivered first holds the key. The other stays undeliverable until its SKU changes or the workspace's product is removed.

A product deleted in the workspace stays deleted. It stays out even while the shop still sells it, and its row stays remote_gone. Bringing it back takes an operator resetting the ledger row; it is not something a later change in the shop does.

A new attribute reaches the workspace's products after up to five minutes. The schema is cached. An attribute the workspace owner has just created is picked up by the next delivery after the cache expires. A workspace with no product yet receives only the model's own fields and conflux_source on its first products, and nothing backfills the attributes afterwards until each product changes.

EAN is part of the data layer now. products.ean is a new nullable column and ProductData::$ean carries it: PrestaShop maps ean13, WooCommerce maps global_unique_id. Rows synced before the column existed carry none until the next full sync, so a product with no SKU needs one before it can be delivered.

A mass deletion in the shop is held, not executed. The volume breaker holds the withdrawals until someone confirms them, as it does for contacts.