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 acustom_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_idanother product holds answers400 {"custom_id": ["product with this Custom ID already exists."]}, on a create and on a rename alike. PUT products/{id}/accepts a changedcustom_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.