Architecture Overview
Table of Contents
Section titled “Table of Contents”- High-Level Architecture
- Core Bounded Contexts
- Capability Abstractions (Business Roles)
- Hexagonal Architecture Structure
- Cross-context dependencies in core
- Module Organization
- Data Flow
- Technology Stack
High-Level Architecture
Section titled “High-Level Architecture”See ADR-001 for the decision rationale.
OpenLinker follows a Hexagonal Architecture (Ports and Adapters) pattern, organized as a modular monorepo. The system is designed to be:
- Modular: Clear separation between core domain and integrations
- Extensible: Easy to add new platforms without modifying core logic
- Testable: Domain logic isolated from infrastructure concerns
- Maintainable: Business capabilities abstracted from concrete implementations
Architecture Diagram
Section titled “Architecture Diagram”┌─────────────────────────────────────────────────────────────────┐│ Frontend/UI ││ (Separate Application) │└────────────────────────────┬────────────────────────────────────┘ │ │ HTTP REST API (JWT) │┌────────────────────────────▼────────────────────────────────────┐│ Core API (OpenLinker) ││ ││ ┌──────────────────────────────────────────────────────────┐ ││ │ Interfaces Layer (HTTP/REST) │ ││ │ - Controllers (REST endpoints) │ ││ │ - Request/Response DTOs │ ││ │ - Authentication & Authorization │ ││ └──────────────────────────────────────────────────────────┘ ││ ││ ┌──────────────────────────────────────────────────────────┐ ││ │ Application Layer (Use Cases) │ ││ │ - ProductSyncService │ ││ │ - InventorySyncService │ ││ │ - OrderSyncService │ ││ │ - OfferSyncService │ ││ │ - MappingServices │ ││ │ │ ││ │ ┌────────────────────────────────────────────────────┐ │ ││ │ │ Infrastructure Services │ │ ││ │ │ - IdentifierMappingService │ │ ││ │ └────────────────────────────────────────────────────┘ │ ││ └──────────────────────────────────────────────────────────┘ ││ ││ ┌──────────────────────────────────────────────────────────┐ ││ │ Domain Layer (Business Logic) │ ││ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ ││ │ │ Products │ │ Inventory │ │ Orders │ │ ││ │ │ Domain │ │ Domain │ │ Domain │ │ ││ │ └──────────────┘ └──────────────┘ └──────────────┘ │ ││ │ │ ││ │ ┌──────────────┐ │ ││ │ │ Listings │ │ ││ │ │ Domain │ │ ││ │ └──────────────┘ │ ││ │ │ ││ │ ┌────────────────────────────────────────────────────┐ │ ││ │ │ Capability Ports (Interfaces) │ │ ││ │ │ - ProductMasterPort │ │ ││ │ │ - InventoryMasterPort │ │ ││ │ │ - OrderSourcePort │ │ ││ │ │ - OrderProcessorManagerPort │ │ ││ │ │ - OfferManagerPort │ │ ││ │ │ - PricingAuthorityPort (future) │ │ ││ │ └────────────────────────────────────────────────────┘ │ ││ └──────────────────────────────────────────────────────────┘ ││ ││ ┌──────────────────────────────────────────────────────────┐ ││ │ Infrastructure Layer (Adapters) │ ││ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ ││ │ │ PrestaShop │ │ Allegro │ │ InPost │ │ ││ │ │ Adapters │ │ Adapters │ │ Adapters │ │ ││ │ └──────────────┘ └──────────────┘ └──────────────┘ │ ││ │ │ ││ │ ┌────────────────────────────────────────────────────┐ │ ││ │ │ Adapters Implementing Capability Ports │ │ ││ │ │ - PrestashopProductMasterAdapter │ │ ││ │ │ - PrestashopInventoryMasterAdapter │ │ ││ │ │ - PrestashopOrderSourceAdapter │ │ ││ │ │ - PrestashopOrderProcessorAdapter │ │ ││ │ │ - AllegroOrderSourceAdapter │ │ ││ │ │ - AllegroOfferManagerAdapter │ │ ││ │ └────────────────────────────────────────────────────┘ │ ││ └──────────────────────────────────────────────────────────┘ ││ ││ ┌──────────────────────────────────────────────────────────┐ ││ │ Infrastructure Layer (Persistence) │ ││ │ - PostgreSQL (TypeORM) │ ││ │ - Redis (Caching, Event Bus) │ ││ └──────────────────────────────────────────────────────────┘ │└────────────────────────────┬────────────────────────────────────┘ │ │ HTTP/API Calls │ ┌────────────────────┼────────────────────┐ │ │ │┌───────▼──────┐ ┌────────▼────────┐ ┌─────▼──────────┐│ PrestaShop │ │ Allegro │ │ Other ││ API │ │ API │ │ Platforms │└──────────────┘ └─────────────────┘ └────────────────┘Frontend-specific conventions for the separate UI application are documented in docs/frontend-architecture.md.
Core Bounded Contexts
Section titled “Core Bounded Contexts”The system is organized into the following core bounded contexts:
1. Identity
Section titled “1. Identity”- Responsibility: User authentication, authorization
- Key Entities: User, Role, Permission
- Location:
apps/api/src/auth/orlibs/core/src/auth/ - An admin creates an account directly, and the one-time password must be replaced before anything else (#3456, epic #3460):
POST /users(@Roles('admin')) creates an ACTIVE account with adisplayName, a login, an optional email and any role - the path for a packer who has no work email and cannot self-register. The response{ id, temporaryPassword }is the only place the password ever exists in plain text: it comes from a CSPRNG, is stored as a bcrypt hash, and is never logged. A taken username or email answers 409 naming the FIELD (never the value); an admin already sees every account, so this discloses nothing, while public registration keeps its deliberately vague collision. Three properties are load-bearing. (1) The forced change is enforced server-side, by a guard:users.must_change_passwordrides on the JWT (the analytics-consent shape, #1938, so there is no per-request read), and the globalPasswordChangeRequiredGuardanswers 403PASSWORD_CHANGE_REQUIREDon every route except the two that carry@AllowPasswordChangeRequired():GET /auth/me(the frontend reads the flag there) andPOST /auth/me/password. It applies to every role on every deployment, because a one-time password is a credential someone else has seen. (2) The change clears the flag in the SAME statement that writes the new hash (updatePasswordHash(..., { clearMustChangePassword })), re-verifies the current password, and refuses a new password equal to it. It answers 400 rather than 401 on a wrong current password so the client’s session handling does not enter a refresh loop, and the client then re-mints its token through/auth/refresh. (3) The consent route and the password route each exempt the other’s gate: an admin-created demoviewerowes both, and without the pairing each gate would block the call that resolves the other. The column defaults tofalse, so no existing account is affected. The change-password screen and the redirect on the code are the frontend’s (#3457).
2. Products
Section titled “2. Products”-
Responsibility: Product catalog management, product mapping between platforms
-
Key Entities: Product, ProductMapping, ProductVariant
-
Location:
libs/core/src/products/ -
Capability: Uses
ProductMasterPortabstraction -
Barcode Storage: EAN/GTIN are stored on
ProductVariant(notProduct), as variants are the canonical offer-link targets. -
Simple Products: Products without combinations produce a deterministic synthetic variant to ensure a stable mapping target.
-
GPSR data travels on the product, by value:
Product.gpsr(ProductGpsr,products.gpsrjsonb) is what a ProductMaster states under the EU General Product Safety Regulation - producer, EU responsible person, importer (each a named party with optional contact and address), and safety information (free text, certificate names) - never a platform registry id, because an id means nothing outside its own platform. Three states, kept apart by the repository: an object is what the master stated,nullmeans it stated the product carries none, andundefinedmeans it did not report GPSR on this read (it does not model it, or its reference read failed) - the upsert then leaves the column unwritten, so one failed reference read cannot erase a correct declaration. This deliberately differs fromfeatures, which the sync overwrites with null. Shoper is the first master to supply it;OfferBuilderServicethreads it ontoCreateOfferCommand.gpsr(live master read first, the stored projection when the live read did not report it). A destination prefers a product-level value over its connection defaults but expresses it only through what the seller already registered there: Allegro matches the producer name (exact after case/whitespace normalisation, against the registry label or trade name) to one of the seller’s/sale/responsible-producersand writes the product’s safety text as aTEXTblock; no match, two matches or an over-long text keepssellerDefaultswith a greppable warning, and a producer is never created on the marketplace - that would be OpenLinker making a legal declaration the seller never made. The offer-update backfill still usessellerDefaultsonly, becauseUpdateOfferFieldsCommandcarries no product data. -
Catalogue prices are gross (#3737, ADR-077):
Product.price/ProductVariant.priceis what a buyer pays in the master connection’s sales country, excluding promotions; absent when the master cannot state it, never a net figure in its place. Conversion is adapter-owned.- PrestaShop: transcribes the shop’s own computed gross for
salesCountryIso2(elsePS_COUNTRY_DEFAULT). It accepts that figure only when it matchesnet × (1 + rate)within a cent, so a US-default shop with Polish tax groups reports no price instead of a net one. - WooCommerce: reads
regular_price, and grosses up with the store’s class × country rate when the store enters prices without tax. - Shop publishers: convert back. PrestaShop writes net plus
id_tax_rules_group; WooCommerce writes net for a tax-exclusive store, in the publish path andupdateShopProductPrice. - Correction wave: the first post-upgrade price move of a previously-net master carries
price_change_episodes.cause = 'tax-basis-correction'and is never auto-applied (driven byproduct_variants.priceBasisPending, set by the migration and cleared by the first priced upsert).
- PrestaShop: transcribes the shop’s own computed gross for
-
Connection-ownership guard on the staleness prune (#1904): neither
product_variantsnorinventory_itemscarries connection provenance, so the master-sync staleness sweeps (MasterProductSyncService’smarkVariantsStaleExcept,MasterInventorySyncService’spruneStaleVariants) key on the internal product id alone - correct only while ONE connection with the relevant master capability claims that id, which is whatgetOrCreateInternalId’s per-(entityType, externalId, connectionId)namespacing normally guarantees. Before every prune (both the partial post-pull sweep and the full-deletion sweep) each service asks the neutralIEntityClaimService.findRivalClaimants(@openlinker/core/integrations) whether another connection claims the same internal id and hasProductMaster/InventoryMasterenabled; on a hit the prune is withheld (never staling a sibling’s live rows), nomaster.*.staleevent is emitted, an error is logged, and the sync result reportspruneSkipped: true(the worker handler logs it with job context;outcomeis unaffected). The claim lookup is one indexedgetExternalIdsread that short-circuits before the capability listing in the single-claimant case, and resolves capable connections vialistCapabilityAdapters({ lazy: true })so no adapter is constructed. Deliberately a detect-and-withhold guard rather than aconnectionIdprovenance column: the collision is unreachable through any supported workflow (it needs directidentifier_mappingsmanipulation), so the conservative runtime check beats a schema change plus backfill semantics. -
Bounded, resumable master sweeps (#2218 / #2219, ADR-048 decisions 4-6): both
master.product.syncAll(every 20 min) andmaster.inventory.syncAll(every 15 min) used to enumerate an entire catalog andmap(...)one child job per product with no cap, into a runner whose execution concurrency is 1 - a 20k-SKU connection enqueued 20k jobs per tick. Each run now enqueues at most a budget of children, records where it stopped on a connection cursor, and the next cron tick resumes; runs are serialised per connection by aSyncLockPortlock whose TTL covers one RUN, never one cycle. The shared shape isrunBoundedSweep(apps/worker/src/sync/bounded-sweep.ts), copied from the scan-offset family (IOfferStatusSyncService/IShopStatusSyncService-{limit, offset}in,nextOffsetout, handler persists the cursor), NOT from taxonomy’s frontier-as-query, which re-derives remaining work from a predicate the master sweeps do not have. Four properties are load-bearing. (1) The budget is derived from drain rate, not copied - taxonomy’s 500 bounds local category upserts, whereas one unit here is a child job doing a full per-product platform sync (~2-5 s) against a single execution slot, soSWEEP_BUDGET_DEFAULT = 100(≈55% of the shorter tick) with a 500 ceiling. This bounds the fan-out; it does not make the catalog fast, and contention between that work and a buyer’s order remains ADR-050’s subject (#2167). (2) The cursor advances only when every enqueue in the page succeeded - resumption removed the safety net that made a partialallSettledfailure tolerable (the next tick used to re-enumerate everything), so a failure now holds the cursor and retries the page rather than skipping ids silently until the next cycle. (3) The child idempotency key is scoped to the CYCLE, not the outer job id - a resuming tick is a different job, so a job-scoped key would re-enqueue the same child under a fresh key on every overlapping page (#2039’sreconcileIdlesson). That also makes a crash between enqueue and cursor write safe: the retry mints identical keys. The cursor value is consequently the repo’s first composite one ({cycleId}:{offset}), parsed defensively so a malformed value starts a fresh cycle rather than wedging the sweep. (4) The oldMAX_PAGESguard is deleted - it warnedpagination may be truncatedand then returnedoutcome: 'ok', so a catalog pastMAX_PAGES x pageSizewas silently half-replicated while the job read healthy. A budgeted run resumes instead of truncating, and nothing throws on a bound (ADR-048 decision 5: on a cron path a throw costsmaxAttempts=10with backoff to 6 h plus one accumulating dead row per tick). A budgeted run reports a plainoutcome: 'ok'like every other sweep; the cursor is the observable that a cycle is in flight. #2219 additionally givesIdentifierMappingQueryPort.listExternalIdsByConnectionan optional page argument (return type staysstring[]) - the inventory sweep reads OL’s own mappings rather than the platform, and that read was a bare unboundedfind({entityType, connectionId}). Optionality is what keeps the ~12 plugin sites that type-depend onIdentifierMappingPortsource-compatible. A paged read is ordered byexternalId, which is unindexed for that partition - an accepted, documented per-page sort rather than an oversight. -
The catalogue sweep fans out one child per PAGE, not per product (#2593, ADR-048 decision 1):
PrestashopProductMasterAdapter.getProductshad always donedisplay=fullplus batched identifier mapping - 100 fully-hydrated products in 3 requests, 0.38 s, 1.33 MB, measured live - and had no caller anywhere in the repo. It could not have one: the sweep fanned out onemaster.product.syncByExternalIdper product, andgetCapabilityAdapterconstructs a fresh adapter per call, so a per-product child could never share a bulk read no matter how cheap the read was. The sweep now enqueuesmaster.product.syncBatch, one child per page of products, and that child runs the per-product loop through ONE adapter instance. Five properties are load-bearing. (1) It is a PREFETCH, not a second sync path. The new guard-only sub-capabilityBulkProductReader.prefetchProductswarms whatever the adapter caches and returns nothing;IMasterProductSyncService.syncFromMasterByExternalIdsthen runs the unchanged per-product body per id, so identifier mapping, variant resolution, the tax-rate journal, the #1904 rival-claimant prune guard and the #1599 deletion signal all still happen exactly where they did. A path returning hydrated products would have had to re-implement every one of them, and a guard re-implemented is a guard that drifts. The consequence is that the prefetch must be invisible except in request count: a master declaring nothing, or one whose prefetch throws, degrades to the reads it always issued. (2) A failed product does not fail the page. The service reports per-id failures instead of throwing and the handler re-enqueues each as an ordinary per-product job, keeping the retry ladder and the dead row the fan-out always had - otherwise one poisonous product would take its ninety-nine page-mates down with it, ten attempts deep. (3) A deletion is logged rather than reported as the job’s outcome, because a mixed page cannot carry the per-productbusiness_failure/master_deletedlabel. That costs nothing real: this path is fed by an enumeration read FROM the master, which is structurally blind to a deletion (decision 2), andmaster.product.reconcile- still a per-product fan-out - remains the deletion authority and keeps the label. (4) Two adapter-side fixes were prerequisites, both silent before.filter[id]joined ids with a comma, which PrestaShop reads as the RANGE between the lowest and highest requested id rather than an OR list, so a page of sampled ids returned most of the catalogue; it is pipe-joined now. And the builder emitted nosort, so offset paging trusted whatever order MySQL returned. (5) The budget was re-derived, and deliberately not raised further.BATCHED_SWEEP_BUDGET_DEFAULT = 500items per run at 100 per batch, against the same drain-rate rule the per-product 100 came from - the unit is now a page hydrated in three requests rather than a child job spending four. The cadence is unchanged at 20 minutes, which is the honest half: past this budget the ceiling is the lane’s per-scope cap, not the shop, so a shorter cron would queue work the runner cannot drain any faster. The batch child runs in thebulklane, notrealtime: it is a catalogue-sweep child, and ADR-050 picks a lane by cost of starvation, which is exactly why #2594 moved the per-product sweep children (master.product.syncFromSweep/master.inventory.syncFromSweep) out ofrealtimein the first place. SoOL_LANE_BULK_SCOPE_CAPgoverns it, and a catalogue cycle can never fill the realtime lane’s slots ahead of a buyer’s order. -
Deletion reconciliation -
master.product.reconcile(#2222, ADR-048 decision 2): the deletion authority for the catalog, and the counterpart to the sweeps above. A catalog enumeration cannot reveal a deletion -master.product.syncAllreads ids FROM the master, so a deleted product simply stops appearing and no child ever runs for it. This pass inverts the direction: it enumerates OL’s ownProductidentifier mappings (paged vialistExternalIdsByConnection, #2219), filters synthetic variant ids, and enqueuesmaster.product.syncFromSweep(the existing per-product sync body; #2594 split the type so the child lands in thebulklane rather thanrealtime) - so a live product re-syncs idempotently and a deleted one raisesMasterProductNotFoundErrorinto the deletion path. That is precisely the shapemaster.inventory.syncAllhas always had, which is why anInventoryMasterconnection already had deletion coverage and aProductMaster-only one had none. Absence is never the signal, and must not become one: stable pagination is not guaranteed on every adapter (PrestashopProductMasterAdapter.listExternalIdssortsid_ASCsince #2627;WooCommerceProductMasterAdapter.listExternalIdsstill sends noorderby), so a cycle spanning many ticks can miss a LIVE row after a mid-cycle delete shifts the offsets - and staling on that inference would zero that product’s offers on every marketplace via #1689. Because the child is the authority the pass carries no guards of its own (an empty enumeration enqueues nothing; a missed mapping is re-checked next cycle; the #1904 guard lives where the write is), takes its own lock and cursor namespace (product-reconcile, a fourthSweepKind) so the full sweep cannot starve it, and uses its own child idempotency namespace so a re-check never dedups against a sweep or delta child. Default ON, unlike the opt-in delta pass (#2220) - it closes a live defect where a deleted product’s offers kept selling, so every existingProductMasterinstall picks it up on deploy as new recurring platform load. Hourly, and not capability-gated to connections lackingInventoryMaster- a capability-shaped exclusion rots and would skip the #1904 case - at a steady-state cost of ~14% more per-product children on a connection carrying both. Note the cron is the tick, not the cycle: a cycle spansceil(N / budget)ticks, so detection latency is ~ceil(N/100)hours at the defaults - about 41 days on a 100 000-product catalogue, which is what #2647 addresses next. -
A master may also TELL us (#2647, ADR-048 § Amendment (#2647)): PrestaShop module 1.9.0 registers
actionProductDeleteand writes aproduct.deletedrow through the existing outbox, so a deletion reaches OL through the ordinary webhook path instead of waiting for the audit. The event is a trigger, never an assertion - the translator decodes it into theproductdomain like any other product event, it routes to the samemaster.product.syncByExternalIdjob, and the master answering not-found is still the authority that stales the variants, emitsmaster.product.staleand pauses the offers; a rolled-back delete therefore costs one redundant re-sync and nothing else. Verified end to end on a 100 000-product stand (perf/prestashop-baseline/results-E-2026-08-28.md): outbox row written synchronously with the delete, job terminal asmaster_deleted, 3 of 3 variantsisStale. Detection latency becomes the shop’s cron cadence - roughly a minute and a half on a one-minute cron, against the audit’s derived ~41.7 days. What this changes is thatmaster.product.reconcileis now the backstop rather than the only route; it is NOT weakened and keeps its cadence, because a webhook is at-most-once at the shop’s discretion and the audit is what survives a lost delivery, a disabled hook, a shop that never upgraded the module, and every master with no delete hook at all. A deletion is also verified not to be swallowed by a page of 100 on the batched sweeps: the prefetch seeds an empty bucket for every id it asked about, so a deleted product is a cache hit with zero rows rather than a miss and the per-item deletion path runs unchanged. -
The master capability ladder - modified-since rung (#2220 / #2221, ADR-048 decisions 1/3/7): a
ProductMasteradapter can now say that it enumerates only what changed since a watermark, via theModifiedProductListersub-capability (listExternalIdsModifiedSince) - the first entry in a newproducts/domain/ports/capabilities/directory.master.product.syncDeltaconsumes it: samerunBoundedSweepbudget/cursor shape as the full pass, its own lock, and a per-connection watermark cursor. Six properties are load-bearing. (1) It is additive and OPT-IN (OL_MASTER_PRODUCT_DELTA_SYNC_ENABLED, default off) -master.product.syncAllkeeps its cadence and stays the bootstrap and reconciliation path, because only a full enumeration may conclude a product DISAPPEARED (decision 2); the two-cadence policy is #2222’s. (2) The rung is guard-only - absent from every manifest and fromCoreCapabilityValues, narrowed off the dispatchedProductMasteradapter. Advertising it would be harmless in itself, but a connection’senabledCapabilitiesis stamped at create and never retro-filled, so gating on the new name would drain nothing for every connection that already exists (the #2085 shape) - and an advertised name invites exactly that gating. The cost accepted is operator-facing discoverability. (3) The delta pass takes its OWN lock. Sharing the full sweep’s would let it starve indefinitely - on a large catalog the full sweep is mid-cycle more or less permanently by #2218’s design - while logging “already in progress” and returning ok, i.e. the delta path looking healthy while being wrong. The price is that a product in both passes is enqueued twice under two cycle ids; that is bounded and harmless because the child is idempotent. (4) The watermark is captured BEFORE the read, overlapped by a configurable lookback, and advanced only when the cycle completes (decision 3, neversince = lastRunAt) - so a resuming tick recomputes the samesinceand the query set is stable across a multi-tick cycle. Re-reading is free only because every downstream write is idempotent. A missing watermark stamps and enumerates nothing rather than meaning “since the epoch”, and warns, since a lost watermark is indistinguishable from a first run. (5) Two silent-degradation modes are made observable: a cycle that never completes never advances the watermark and quietly becomes a permanent full pass (warned on watermark age), and offset paging over a liveorderby=modified ascset can step over a row that was re-modified mid-cycle - accepted, and survivable only while the full pass still runs, which is recorded in ADR-048 as a hard dependency for #2222. (6) Only WooCommerce declares it, and it reports product-level freshness only - a variation edit does not bump the parent’sdate_modified(WC #19562) and stock is not on this rung at all (update_product_stock()bypasseswp_update_post()). Shoper declares it too, over itsedit_datefilter, with its own blind spots (a variant deleted through the API, an image change and a stock return do not bumpedit_date) and results ordered byproduct_idrather than by the timestamp, so a row re-edited mid-cycle cannot move; the full pass stays its backstop for the same reason. PrestaShop declares neither rung after the #2221 spike measured it: a combination write leaves the parent’sdate_upduntouched while a product write moves it, and neitherps_product_attributenorps_stock_availablecarries any mutation timestamp to union against. No inventory rung is written at all - decision 1 forbids an interface with no implementer, and after the spike noInventoryMasterin the tree can implement one.
3. Inventory
Section titled “3. Inventory”-
Responsibility: Inventory synchronization, stock level management
-
Key Entities: Inventory, InventoryAdjustment, InventoryMapping
-
Location:
libs/core/src/inventory/ -
Capability: Uses
InventoryMasterPortabstraction -
Variant-keyed master inventory (#822, ADR-010):
MasterInventorySyncServicekeys eachinventory_itemsrow to the product’s canonical variant (a simple product’s deterministic synthetic variant), not the bare product — so the variant-keyed availability read (getAvailabilityByVariantIds, used by the bulk offer wizard) finds stock. Resolution precedence: adapter-suppliedInventory.variantId→ the product’s lone variant when it has exactly one → else product-level (productVariantId = NULL). -
Per-combination master stock (#823): the master sync iterates
InventoryMasterPort.listInventory(productId)— oneInventoryper variant — and writes one variant-keyed row each. The PrestaShop adapter enumeratesstock_availablesfor the product and emits one entry per combination (resolving each combination id to itsProductVariant), or the synthetic variant for a simple product, so multi-variant products now carry correct per-variant stock. Marketplace-agnostic: any inventory master expresses per-variant stock the same way. The Allegro destination half (one auto-grouped offer per variant) remains #824. -
Master-side deletion (#1688): a product deleted at the inventory master surfaces as the neutral
MasterProductNotFoundError(from@openlinker/core/products) at theInventoryMasterPortboundary — each adapter translates its own platform not-found there, so no platform exception type reaches core (the inventory-context counterpart of the products-side #1599 path).MasterInventorySyncServicecatches it, marks every one of the product’sinventory_itemsrows stale via an empty keep-set, and then delegates to the products context (IMasterProductSyncService.markProductDeletedAtMaster, #2222) rather than emitting itself - the delegate stalesproduct_variants, emitsmaster.product.stale, and applies its ownProductMasterrival guard, whose withheld outcome propagates back intopruneSkipped. Until #2222 this path staledinventory_itemsONLY, whileStaleOfferPauseServicere-verifiesproduct_variants.isStalebefore pausing anything - so the whole #1689 chain fired and paused nothing, and a product deleted at the master kept selling. One deletion, one event, one authority. It reportsmasterDeleted: true;MasterInventorySyncHandlermaps that to a terminaloutcome: 'business_failure'(ADR-007) so the runner does not retry a permanent condition. The neutral error is reserved for a platform-reported product absence — a mapping gap, a corrupted mapping, or a merely inferred absence (e.g. “the product resolves but carries no stock rows”, which the PrestaShop adapter disambiguates with a product probe) stay platform-native and therefore retryable. A partial removal (some variants gone, product still resolves) instead flows through the normal sync’s unconditional variant prune, which emitsmaster.variant.stale; an empty-but-successful master response that stales every known row is warn-logged, since it reportsmasterDeleted: false/outcome: 'ok'. -
An adjustment can say WHY, and can refuse to happen twice (#2368, design § 7.3):
InventoryAdjustmentgains an optionalidempotencyKeyand a narrowed, closedreason(return_restock|manual_correction), andadjustInventoryreturns anInventoryAdjustmentResult— the sameInventoryplus an OPTIONALadjustmentOutcomereportingdisposition(applied|deduplicated),idempotency(honoured|unsupported|not_requested) and the master’s ownappliedAt. It exists because #2370 restocks returned units THERE, in the master, sinceinventory_itemsis a mirror rewritten by every master sync — so an OL-side increment silently vanishes at the next tick — and a retried restock against a master with no conditional stock write would double-increment while the master’s audit showed an unexplained delta. Four properties are load-bearing. (1) Every added member is OPTIONAL, so the amendment is a source-compatible widening: an existing implementer declaringPromise<Inventory>still satisfies the signature and an existing caller readingInventoryfields is untouched — the disciplinelistExternalIdsByConnection’s page argument was added under (#2219), and the reason a required key was rejected outright. The ONE compatibility cost is narrowing the pre-existingreason?: string, which was written by nobody and read by nobody in this tree; it lands on CALLERS, never on implementers (a narrower type is still assignable to a string read), and an audit value any caller may spell freely cannot be attributed or aggregated, which is the whole point of carrying it. (2) An absentadjustmentOutcomemeans “not reported” and a caller MUST read it asidempotency: 'unsupported'— a pre-#2368 adapter reports nothing, and reading that as a honoured dedupe would let a retry silently skip a restock that never happened. (3)appliedAtis the MASTER’s instant, nevernew Date()— the change happened in another system and OL’s clock is not a witness to it (the #2336declinedAtrule, restated by #2367’s custody transitions);nullis an honest absence rather than “now”. WooCommerce readsdate_modified_gmtonly, sincedate_modifiedis site-local with no offset. (4) An adapter that CANNOT dedupe says so rather than pretending. WooCommerce honours a key by remembering it in the sharedCachePortfor seven days (matching thejobdedup:*TTL, so the retry ladder’s six-hour backoff cannot outlive the window), recorded only AFTER the write succeeded — recording first would suppress the retry of an adjustment that never landed, the exact failure inverted — and a dedupe hit re-reads and returns the master’s CURRENT stock rather than replaying a stale figure. The window is deliberately bounded and honest about its bound:CachePortexposes no atomic set-if-absent, so two genuinely concurrent same-key calls can both apply; the threat this closes is the SEQUENTIAL job retry, and #2370 mints one deterministic key per disposition sequence (return:{returnId}:{lineId}:{seq}, never wall-clock) so two operators never share one. A host wired without a cache applies the adjustment and reports'unsupported'— never'honoured', because a caller that believes a key was honoured stops defending against the double-increment itself. WC carries no stock-reason field, so the reason reaches the operator’s log rather than the master’s audit trail. PrestaShop honours a key the same way and against the same sharedCachePort, and adjusts by reading the currentstock_availablesrow, computing the new total and writing the FULL resource back (landed in PR #2675; neither #2077 nor #2369 appears in any commit onmain, so the PR is the citable record) — the webservicePUTtakes the whole body, so the read-back row is spread and onlyquantityoverlaid, orid_shop/depends_on_stock/out_of_stockwould silently revert to PrestaShop’s own defaults; it reportsappliedAt: nullfor the reason in (3), since the resource carries no instant, and routes the reason to the operator’s log becausestock_availableshas no audit field andlocationis an operator-authored warehouse string a reason would corrupt. Three configurations are REFUSED rather than written through, each naming its remedy: more than one matching row, which is a multi-shop install with noshopIdscoping the connection and where a silent pick would move real stock in the wrong shop; no matching row at all; anddepends_on_stock = 1, where Advanced Stock Management recomputes the quantity from warehouse stock, so a write would be accepted and then discarded. A negative delta past zero is clamped to0and warn-logged rather than refused, so less than the requested delta is applied. -
Per-connection stock safety buffer (#1844): an operator-configurable reserve held back per destination connection so a fast-moving item cannot oversell between syncs. The published quantity is
max(0, masterStock - reserve). The reserve lives onConnection.config.stockSafetyBuffer(JSONB — no schema/migration change) and defaults to0, which preserves the pre-#1844 pass-through behaviour (master is authoritative, including 0). Two pure, marketplace-neutral helpers own the policy (readStockSafetyBuffer/applyStockSafetyBufferin@openlinker/core/identifier-mapping, mirroring theparseTriggerModelconfig-coercion precedent). It is applied at both stock write sites and applied once per flow: the publish/create builders (OfferBuilderService,ProductPublishBuilderService) stamp the bufferedcommand.stock, and the inventory write-back choke point (InventorySyncService.updateOfferQuantities, reached by both the offer and shop-product propagation fan-outs) buffers every written-back quantity. The buffer is orthogonal to the FE bulk stock policy (use-master/ cap / flat, #726); it is the destination-side floor applied after the operator’s chosen source quantity. Since #2323 the buffer is a Control owned byIAvailabilityService, with exactly one reader (ADR-061 decision 3): the four sites above no longer import the helpers — they callapplyPublishControls({quantity, scope}), which performs the identicalmax(0, quantity − reserve)and additionally returns aprovenance, so a Control resolution that FAILS reports'unknown'and the caller suppresses its write rather than publishing straight through the operator’s cushion (the write-back fails every item witherrorCode: 'availability_unknown'and never reaches the marketplace; each builder throws the retryableAvailabilityUnknownError, deliberately not a validation exception, so ADR-007 keeps it a retry rather than a terminalbusiness_failure). One operator-facing surface needs to display the cushion; it asksgetAppliedReserve(scope)rather than re-reading the helpers.libs/core/src/__tests__/no-direct-buffer-read.spec.tsfails the build on any import of a buffer helper outside its owner and that seam — with no exemptions, since a “display only” carve-out is how four copies appeared the first time. In the same sliceIInventoryQueryService.getAvailabilityByVariantIdsgained a requiredavailableToPromise(global scope, no buffer) that the two publishing consumers — the bulk wizard’s master-stock resolve and the cancellation stock-restore — read instead oftotalAvailable;nullthere means OL does not know, and both EXCLUDE the variant rather than fall back (a fallback publishes the un-reserved quantity; a0would deactivate a live offer via the #1689 pause primitive). On a Wave-1b empty-ledger install every published number is byte-identical, asserted against one shared fixture byapps/api/test/integration/publish-quantity-parity.int-spec.ts. -
Located stock propagates, and propagation is location-blind (#2324, ADR-058 decision 5) — the wave’s ONE plugin-breaking change. Until #2324
InventoryService.setInventoryrefused to enqueueinventory.propagateToMarketplacesfor any row carrying alocationId, and the handler on the other end read a single(product, variant, location = NULL)row. Those two halves were consistent and jointly catastrophic: a master that locates its stock had every write skipped, silently, so the marketplace kept publishing the last pooled number it ever saw — no error, no failed job, no counter. Both halves change together. The write side enqueues unconditionally; the read side asksIAvailabilityService.getPromisableQuantitiesfor the variant’s available-to-promise in theglobalscope, i.e. the aggregate across every live position — all locations and all sources (ADR-058 decision 2 — a cross-source sum is legitimate coexisting mirrors, and deduplicating physical stock is #2319/#2325’s problem, deliberately not this seam’s). The scope is global and notchannelbecause the destination’sstockSafetyBufferis a Control applied exactly once downstream byInventorySyncService.updateOfferQuantities(#2323); asking for a channel scope here would buffer the same number twice, and this handler fans out to many connections from one read, so there is no single channel whose cushion it could defensibly borrow. Four properties are load-bearing. (1) The dedupe key stays LOCATION-FREE and the omission is the point — a master reporting N located positions in one pull writes them with a sharedupdatedAt, so N enqueues collapse into one job, and one job is the correct number because that job publishes the aggregate; addinglocationIdwould fan out N identical publishes. It is never quantity-derived (#2285). (2) The no-change guard stays ROW-scoped, which is sound under an aggregate publish precisely because each changed sibling enqueues its own job and the handler re-reads the whole aggregate — ANY one enqueue publishes the correct total — while making it aggregate-aware would put an N+1 read on the hottest write path in the system. (3)provenance: 'unknown'THROWS a retryableSyncJobExecutionError, it is not swallowed asoutcome: 'ok'(log tokeninventory_propagation_suppressed_availability_unknown, spelling parity with #2323’s write-back arm):inventory.propagateToMarketplacesis event-driven with no cron backstop, so an ok-swallowed propagation is stock drift until the next unrelated write — ADR-048’s “a throw on a cron path costsmaxAttemptswith backoff” argument does not apply to a path no cron re-drives. (4) The early!inventoryreturn is gone, so a variant with no live positions now publishes a known zero rather than nothing — correct per #1844’s master-is-authoritative-including-zero rule and required by #1689’s stale-variant pause, and a real behaviour delta, kept observable by a warn onobservedAt === null. The #1689 stale guard, both fan-out branches and both downstream idempotency-key schemes are byte-identical. #2324 also closes the transition gap #2322 documented: staling a pooled position changes the variant’s aggregate while writing no inventory row, sosetInventorynever fires for it —MasterInventorySyncServicenow enqueues the same variant-keyed, location-free propagation itself when its pooled-position repair marks anything stale (best-effort, unlikesetInventory’s fail-fast, because the pull’s writes are already durable; a same-tick collapse with a locatedsetInventoryenqueue is desirable, not a hazard). No adapter in this repository is affected — WooCommerce leaveslocationIdundefined and PrestaShop never sets it — and the operator-facing opt-out is one line in an out-of-tree adapter (locationId: undefined). Detection query, aggregate semantics and the alertable log tokens live in docs/operations/inventory-location-propagation.md. -
Bounded, resumable inventory sweep (#2219, ADR-048):
master.inventory.syncAllis budgeted, cursor-resumed and per-connection-locked - see the identical shape described under Products above, which both sweeps share viarunBoundedSweep. Two things differ here. It enumerates OL’s own identifier mappings, not the platform, so #2219 additionally gaveIdentifierMappingQueryPort.listExternalIdsByConnectionan optional page argument (that read was previously a bare unboundedfind({entityType, connectionId})); and the synthetic-variant filter runs after the page is read, so the cursor advances by rows READ rather than children enqueued, or the filtered rows would be re-read every tick. Note this sweep carries more weight than its cadence suggests:inventory.propagateToMarketplaceshas no cron of its own, so on a master with no stock webhook it is the only thing that discovers stock drift - it is paced, never disabled. -
The advisory reservation ledger (#2343, ADR-061 decision 1):
reservationsis OL’s own record of what it has PROMISED, one row per(orderRecordId, orderLineId, inventoryItemId). Creating one never decrementsavailableQuantity- OpenLinker does not own on-hand stock - it reduces what OL is willing to promise, and asserts nothing about what the fulfiller will physically pick. Three schema choices are the design: the uniqueness index is partial onstatus = 'held', which IS the idempotency key (a retried reserve conflicts instead of double-incrementing, while a released line can be re-reserved later without colliding with its own terminal history); the key carriesorderRecordId, becauseorderLineIdis the source-supplied id and collides across orders trivially; and the ONE foreign key isinventoryItemId -> inventory_itemswithON DELETE RESTRICT.expiresAtis mandatory - an unbounded hold on a system that may never observe the close event is an oversell leak with no floor. Every transition is a guarded conditional UPDATE (WHERE <precondition> RETURNING,affected > 0as the answer): an unlocked read-then-act is the shape ANALYSIS-1032 § 6I exists to replace, and the failure mode of getting it wrong is an oversell, so nothing reads a value and then decides on it in application code.inventory_items.olReservedQuantityis denormalised over the ledger and the ledger is authoritative. It sums BOTH stamps, so every reader of it must scope byatpEffectfor itself (#2628 review) - the counter is the honest total of what OL has recorded, anddiagnosticmeans “record this, let it restrict nothing”, so anatpEffect-blind reader attributes an operator-visible consequence to a hold that promises nothing. That is not theoretical on the DEFAULTomp_fulfilledtopology: the marketplace ships, so OL creates noShipmentand neither ofcloseForOrder’s two production callers (cancellation, OL-owned dispatch) ever fires, while the expiry sweep releases nothing untilorder_holdsis bound - so a normally-fulfilled, never-cancelled order’s holds stayheldand the counter climbs for the life of the install. Both counter-blind readers therefore degraded on a perfectly healthy catalogue: the shortfall reconciler opened a permanent, never-clearing episode naming a real order, and the admission guard refused every reserve past the stock level - swallowed byreserveOrderInventory, leaving the order invisible to the ledger AND to shortfall detection. Both now subtractpublishedReservedSum(Σ quantity WHERE status = 'held' AND atpEffect = 'published', one shared SQL definition), never the counter. The guard additionally EXCLUDES its own ledger row from that sum, becauseclaimOnewrites the row before it moves the counter and would otherwise test the claim against its own units; excluding it also makes the new predicate the exactatpEffect-scoping of the old one, sinceavailable - counter_total >= deltais algebraicallyavailable - others >= quantity. Known limitation (#2628 review): position resolution groups candidates by(productId, productVariantId)and refuses to guess when a line resolves to more than one, so on an install where a variant carries two live positions - twoInventoryMasterconnections claiming it, or two locations (#2313) under one source - EVERY line raisesAmbiguousReservationPositionErrorand nothing is recorded at all: an empty ledger, an ATP subtraction that is permanently a no-op, a shortfall reconciler that can never open an episode, and one error log per order. That is an asymmetry with the availability path rather than a bug in it (ADR-058 decision 2 treats multiple positions as normal and #2321’s numerator sums across them), and it is deliberate: summing is well-defined, whereas promising requires choosing which position the promise lands on. CarryingsourceConnectionIdthrough the grain does NOT fix it - it re-partitions one ambiguous group into two and leaves “which source fulfils this line” unanswered, and does nothing for the single-source multi-location shape. The fix is a documented selection policy, an OMS routing decision not in this wave. -
Consuming a reservation at dispatch (#2347):
inventory.reservations.consumecloses an order’s held reservations once its shipment shipped, claimed at-most-once throughShipment.reservationConsumedAt- thewaybillRelayedAtconditional-claim idiom (#1947), which is also the serialization point between the sweep and any concurrent trigger. Terminal status'consumed', distinct from'released'so the ledger records WHY a promise ended. More frequent than the expiry sweep (10 min vs hourly) deliberately: this pass RELEASES available-to-promise the operator can resell, so lag is lost sales rather than a safety risk - the opposite direction from expiry, where waiting is the safe choice. Consume-then-claim ordering: the ledger close commits before the marker is stamped, so a crash between them re-runs a close that is idempotent, rather than marking a shipment consumed whose units were never given back. -
A destination that is also the hold’s master consumes it at order creation: when
OrderSyncServicecreates an order at a destination that also masters the reserved position (one shop in both roles - PrestaShop, WooCommerce, Shoper), that shop lowers its own stock right then, so apublishedhold for the same units would subtract them a second time until the dispatch consume above, for days on an unshipped order. ANALYSIS-1032 § 6 C3a accepted only a window from dispatch to the next master sync, on the premise that OpenLinker never observes the master’s decrement; here it authored it.IMasterAbsorbedReservationServicetherefore consumes ('consumed', the #3480 meaning: the reduction moved to the master) everypublishedhold whose position’ssourceConnectionIdis exactly that destination, throughcloseForOrder, BEFORE the post-sale master re-read is enqueued so that re-read’s propagation never sees the hold. Holds on another master’s positions stay;NULL/'legacy'provenance stays too, counted and warn-logged, because closing a hold whose master did not decrement would oversell. A skipped create (the order already existed) runs it as well and republishes itself, since its re-read already ran - that is how a hold opened before this step closes on the next re-ingestion. The premise is declared, not assumed (#3814 review): the consume runs only for a destination whose adapter declares the optionalOrderCreateStockEffectsub-capability and answerstruefromlowersStockOnOrderCreate(), probed after the create (ADR-046 probe-not-trust: no method,false, a throw or a rejection all read as “did not lower”). Shoper answers from its ownshopping_update_stock_on_buysetting, so a shop that keeps stock in an ERP or WMS keeps its holds. PrestaShop, WooCommerce and Subiekt declare nothing yet, so on those destinations the holds stay until dispatch, exactly as before this step - an under-sell, never an oversell - until each is verified to lower stock on create (a WooCommerce order in a status that does not reduce stock, or Subiekt without ZK reservations, would not). The same skip path is also the only point a destination hears about an order again, so it asks the optionalOrderContactRefreshersub-capability (best-effort, never after a fresh create) to reconcile contact details the source revealed only after the first create - Shoper uses it to replace the placeholder phone an unpaid marketplace order had to be created with. -
Reservation shortfall episodes (#2349, design § 4.2 story I6): when the master drops below what OL already promised (
olReservedQuantity > availableQuantity), the operator sees a shortfall on a named order, never a silently clamped number - which is why § 4.2 declines theolReserved <= availableCHECK, so the state is persistable.reservation_shortfall_episodesrecords it as an EPISODE with a stable occurrence id, not a self-clearing flag: a partial unique index on(orderRecordId, inventoryItemId) WHERE "closedAt" IS NULLmakes a re-detection CONFLICT, so the id survives untouched for the life of the condition and an edge-triggered automation (W2-23’s T8) has something to key an idempotency key on; a recurrence after a close mints a NEW id. The conflict arm REFRESHES the quantities (#2628 review) - a frozenshortQuantitywould leave the row asserting a figure nothing recomputes after a partial recovery - while never touching the id. Attribution is youngest-reservation-first, a stated OL policy (“the last promise made is the one at risk”), not an inference about which buyer goes unserved. Four close reasons, all explicitclosedAtwrites:recovered,reservation-closed(the order no longer holds there - cancellation, dispatch or expiry, so a CANCELLATION closes an episode just as a recovery does),no-longer-attributed(still short and still held, but attribution now lands none of it here), andposition-stale(#2628 review). The last exists becauserecoveredis inferred from ABSENCE from the short set, and every shortfall read filtersisStale = false- so when #1689 stales a position because the master deleted the product, the row drops out and a three-reason close asserted that stock came back for a product that no longer exists. A staled position is disambiguated by a positivelistStalePositionIdsread, the only placeisStaleis consulted affirmatively; the arm sits belowreservation-closed(a fact about THIS order’s hold is truer than one about the position) and aboverecovered. Attribution reads the samepublished-only set the predicate does, so adiagnostichold can neither be named in an episode nor absorb a share that would hide the published order that caused it. The close pass is driven from the EPISODES, not the positions - a recovered position simply stops matching the predicate, the same inversionmaster.product.reconcile(#2222) makes for deletions.inventory.reservations.shortfallis budgeted and scan-offset resumed in both halves, which is a correctness choice rather than a copy of the sweep family: this pass REPAIRS nothing, so a short position stays in the predicate and frontier-as-query would re-read the same head page forever, leaving anything past page one permanently invisible. It writes to no other table - no counter is clamped, no reservation touched - because repairing the counter would erase the evidence and restore the silence the missing CHECK exists to prevent. A position short with no held reservations names no order, so no episode is written, but the counter-versus-ledger disagreement is error-logged and counted rather than passed over. -
Operator surface for a shortfall (#2350): the episode reaches an order-row badge and an order-detail callout. One copy source (
features/orders/lib/stock-at-risk-copy.ts) serves both andW2-19’s attention table, so byte-identity is structural - a second copy of the sentence cannot exist without deleting an import. An empty array is never a positive claim: both the detail loader and the batched list read catch to empty on failure, so absence and failure are indistinguishable and no “no shortfalls” reassurance is ever rendered. The badge sits in the row’s Status group beside order health, never inside it -OrderHealthValuesis a partition whose values must sum to the KPI cards, so a shortfall value there would double-count or hide a sync failure behind a stock one (the trap #2100 declined when it shipped a non-partitioning field instead of a sixth bucket). The API field isreservationShortfalls, deliberately NOTstockAtRisk, whichlistingsalready owns for a different variant-keyed question. #2349 put the field on the DETAIL read only to avoid an N+1; #2350 adds a batched list read beside it (one query across the page’s order ids, thegetLatestInvoicesForOrders#1713 precedent) because otherwise the row badge is permanentlyundefined- dead code that type-checks. The product-row badge is deferred: no backend read exposes a shortfall at any product grain. -
Locations are first-class rows (#2313, ADR-058 decision 1):
inventory_items.locationIdwas nominally location-aware but pointed at nothing — no location table existed anywhere in the tree.inventory_locationsis the operator-authored row it now names:code(unique, normalised in exactly one place — the application service — because the index is case-sensitive),name,kind,status,externalRef, pluscountryIso2/postcode/ optionalnumeric(9,6)geo carried from day one because the fulfillment router’s filters are unimplementable without them and the table is cheapest to get right while new.ownerConnectionIdis provenance, never authority: it records whose sync may write positions here, not who decides anything about them — authority over a position is separately assignable (ADR-052), and its FK isON DELETE SET NULL(theFK_category_mappings_source_connectionprecedent) so an operator’s warehouse outlives the integration that stocked it. Two absences are deliberate and load-bearing.locationId IS NULLmeans “the MASTER declines to locate its stock” (decision 2) — never a default location, and OpenLinker never invents one the master withheld. Narrowed by #3206 (ADR-058 § Amendment (#3206)): the rule binds the master’s silence, not the OPERATOR’s assertion, so a per-connectionstockLocationOverridemay supply that answer instead — applied asinventory.locationId ?? override ?? nullbefore the row is written, with a real adapter-reported location always winning. No synthetic DEFAULT location is minted anywhere, and a persistedlocationIdis therefore never a NULL standing in for something: it is the master’s answer, or the operator’s, or genuinely absent. And no FK is added toinventory_items.locationId: existing values are unattributable, making that a step-(iii)-class change under decision 3, which is why the #1904 withhold guard above stays in force as the documented fallback.Locationis deliberately absent fromCoreEntityTypeValues— that union is the external-mapping vocabulary and a location has no external counterpart (externalRefis a free-text operator field, not an identifier mapping); ids are mintedol_location_*straight fromformatInternalId, theShipmentRepositoryshape. This slice is persistence only; the CRUD API is #2316. -
Position provenance, and the
'legacy'sentinel backfill (#2317, ADR-058 ladder step (ii)):inventory_items.sourceConnectionId(added nullable by #2314) records which connection’s sync owns a position. Step (ii) is the pass that makes the column trustworthy enough for step (iii) (#2325,SET NOT NULLplus folding provenance into the position key): theinventory.provenance.backfilljob stamps every pre-existing row withLEGACY_SOURCE_CONNECTION_ID('legacy', declared once ininventory.types.tsand pointed at by the ORM column comment and the #2314 migration), a bounded page at a time, until none remain. The sentinel is a VALUE, never a wildcard - it names one position whose owner is unknown, and a real sync overwriting it with its own connection id is the correct and only direction of travel. Four properties are load-bearing. (1) It is a backfill, not a migration. Stamping inside DDL would hold anACCESS EXCLUSIVElock across every row of the table every published quantity derives from; the pass instead runs oneUPDATE ... WHERE id IN (SELECT ... LIMIT n FOR UPDATE SKIP LOCKED)per tick, so a row mid-write by a buyer-facingsetInventoryis skipped rather than waited on. (2) The predicate IS the cursor, sorunBoundedSweepis deliberately not called.bounded-sweep.tsitself distinguishes the scan-offset family (a stable set a run reads through) from taxonomy’s frontier-as-query (remaining work re-derived from a predicate), andsourceConnectionId IS NULLis unambiguously the second: every page CONSUMES its own selection, so an advancing offset over a shrinking set would step over rows and leave them unstamped - a silent gap surfacing only as #2325’sSET NOT NULLfailing months later. The sweep primitives are reused (budget, lock TTL, lock key, cursor keys) and only the offset machinery is not; the ACs the shape exists to deliver - budgeted, per-run locked, never advancing past unfinished work, self-terminating - hold structurally, because a failed page persists nothing at all. (3) The budget counts ROWS, not children, so the shared 100 default does not transfer - that number is derived inbounded-sweep.tsfrom a child job doing a full per-product platform sync against an execution concurrency of 1, whereas one unit here is a row in a local UPDATE. The page is the family ceiling of 500, which makes the cron the drain rate (~6k rows/hour at the default*/5). (4) Completion isremainingNull === 0ALONE, never “the page stamped nothing” - a page can legitimately stamp zero rows while work remains, when every candidate was locked and skipped, and latching on that would switch the pass off permanently over a transient contention window. The job runs globally under the nil-UUID system connection id (the predicate’s whole subject is the absence of a connection axis; electing a real connection was rejected because the installs with the most NULL rows are exactly those whose original connection was deleted, and a moving election would move the latch). It is default ON - no platform calls, bounded, idempotent, and it un-gates a correctness fix - and self-latches on twoconnection_cursorskeys that are also #2325’s readiness artefact:master.inventory-provenance.completedAt:connection:{system}andmaster.inventory-provenance.remainingNull:connection:{system}. Deleting the completion row re-arms the drain, which is safe at any time.remainingNullis honest about its own staleness - a caller with no connection axis can insert a fresh provenance-less row a moment later, so #2325 re-counts before it acts. -
Provenance is part of row identity on the write path (#2320, ADR-058 decision (4)):
findByProductAndVariantignoredsourceConnectionId, so where twoInventoryMasterconnections claimed the same internal product id, connection B matched connection A’s row and took the UPDATE branch - overwriting A’s quantities and provenance on every tick (the #2314 “flapping”). An index could never have caught this: the clobber is an UPDATE of a wrongly-matched row, so the unique index is never consulted; the fix has to be in the lookup. The lookup now takes an OPTIONAL provenance axis and the prune an optionalProvenanceScope{sourceConnectionId, includeUnattributedProvenance}- the shared vocabulary #2322 consumes - with one predicate builder serving both. Five properties are load-bearing. (1) The null/undefined reading is deliberately ASYMMETRIC between the identity columns and the provenance axis: forproductVariantId/locationIdboth mean “column IS NULL” (a product-level row genuinely IS the NULL-variant row), while for provenance both mean “no axis - behave exactly as before #2320”. Reading provenancenullas match-NULL is a real defect, not a nuance: the axis-less callers would stop finding rows that already carry provenance and each would insert a duplicate, which #2314’s “stamps provenance onto an existing NULL row in place” spec exists to catch. (2) NULL and'legacy'are ONE class - “unattributed” - and a scoped lookup always claims it, with no rival check: the repository cannot reach the claim service (layering), and refusing to claim would insert a duplicate row on every single-source install, i.e. cause the regression the slice must avoid. Treating the two spellings as one class is also what makes the #2317 sweep’s progress irrelevant to correctness here. (3) The PRUNE keeps the rival gate. BothMasterInventorySyncServicecall sites passincludeUnattributedProvenance: true, which is safe ONLY because the prune is unreachable unlessisPruneBlockedByRivalMasterreturned false - recorded as an invariant comment at both sites, since a refactor moving the prune above the guard would make the claim unsafe. #1904 and itspruneSkippedreporting are unchanged, and retire with step (iii), not here. (4) The provenance predicate is always bracketed. It composes with the prune’s variant-keep OR-group, and an unbracketeda OR bbesidec OR dre-associates into a predicate that stales another connection’s rows. (5) A located collision is a typed error, not a raw driver failure. With the lookup scoped, a second source correctly attempts its own INSERT; at a NULLlocationIdthe partial unique indexes are NULL-distinct and both rows are admitted (cross-source coexistence, decision (2)), but at a NON-NULLlocationIdthe insert is refused. That condition is PERMANENT - every retry re-runs the identical statement - so it surfaces asInventoryCrossSourcePositionConflictErrorplus a greppableinventory_cross_source_position_conflictlog naming #2325 (the four-column index) as the fix, rather than burning a retry ladder on a state no retry can change. Nothing routes around the constraint: falling back to an UPDATE would reinstate exactly the clobber being removed. The flapping therefore ends for RIVAL-attributed rows and deliberately survives for unattributed ones, which is what the claim rule is for.InventoryFilters.sourceConnectionIdthreads the same axis into the read path with STRICT equality - a read never claims unowned rows, because reporting another connection’s unattributed stock as this one’s would misstate whose inventory an operator is looking at. No schema or index change: that is #2325. -
locationId IS NULLmeans the master DECLINES to locate, and the sync enforces it (#2322, ADR-058 decision (2)): a pooled row is an absence of an answer, never a location named “default”. A source that starts reporting a variant AT a location therefore leaves its own pooled row behind holding the same stock a second time, and every read that sums positions double-counts it.MasterInventorySyncServicerepairs that on the write path: after thesetInventoryloop and after the #1904 rival check, it soft-stales the SAME source’slocationId IS NULLrows for exactly the variants it just located (markLocationlessStaleForSource, the secondisStalewriter on the table, reusing the #1478 mechanism - no DELETE, noupdatedAtbump, the row simply leaves availability through theisStale = falsefilters every read already applies). Five properties are load-bearing. (1) A REPAIR, not a refusal - the located write has already happened, refusing it would leave the master’s own answer unrecorded, and a DB constraint cannot express the rule at all before the four-column index (#2325). A read-time filter would be wrong for a different reason: a DIFFERENT source’s pooled row is legitimate stock that must keep summing, which a read cannot tell apart. (2) TheProvenanceScopeis REQUIRED here, unlikemarkStaleExceptVariants’s optional one - an unscoped sweep would stale a rival master’s legitimately-pooled row on the strength of THIS master’s decision to locate, a decision that says nothing about the rival’s stock, so the type offers no unscoped form.includeUnattributedProvenanceis passed as!pruneSkipped, mirroring the prune’s invariant: claiming a NULL/'legacy'row is safe only where this connection is the soleInventoryMasterclaiming the id, and with a rival present the repair falls back to strict matching rather than staling a row it cannot prove is its own. Enabling the unattributed branch is what makes the fix reach the installs that actually HAVE the bug, whose pooled rows predate provenance entirely. (3) Nomaster.variant.staleevent. Re-locating is not a master-side deletion; emitting off this count would reachmarketplace.offer.pauseStaleand zero live offers for stock that is still there (#1689), so the count is kept strictly separate frompruneResultand reported as the optionalMasterInventorySyncResult.pooledPositionsStaledinstead. (4) Reversal was half free, and #3206 paid for the other half -isStaleis master-owned on upsert, so a source that stops locating re-creates and un-stales its pooled row through the ordinary write with no code at all. Its abandoned LOCATED row, however, survives the ordinary prune, whose granularity is per-variant rather than per-location (markStaleExceptVariantskeeps every location row of a still-present variant) - so the total double-counted on the way back. #2322 shipped decision (2) in one direction only because no adapter could reach the other one; the per-connection stock-location override made it onePATCHaway, so the mirror repair now ships beside it (see the #3206 bullet below). Multi-location pruning - a master that drops ONE of two locations - is still out of scope, and the #2322 int-spec still asserts the real number for the primitives it drives on their own. (5) No synthetic DEFAULT location, ever - minting one would make the two shapes comparable by inventing the answer the master declined to give, which is the alternative ADR-058 rejects by name; nothing here changes schema, indexes or migrations. An empty located set returns without touching storage at all, which is the whole of the in-tree behaviour today (WooCommerce sendslocationIdundefined and PrestaShop never assigns it), and a payload contradicting itself (one variant reported both pooled and located) warns distinctly with the located position winning. Known gap: on the transition sync available stock drops for those variants with NO propagation enqueued behind it, so destinations keep the old quantity until the next ordinary write -inventory.propagateToMarketplacesis not fired here, deliberately, and #2324 closes it. -
A connection may be TOLD where its stock is, and clearing that answer is a repair too (#3206, ADR-058 § Amendment (#3206)): neither shipped
InventoryMasterPortadapter can report alocationId- PrestaShop only through the rarely-enabled Advanced Stock Management module, WooCommerce not at all - so every position is permanently pooled, the fulfilment router’sloadStock()skips it, and an install with active locations and routable rules still resolves every lineunfulfillable.Connection.config.stockLocationOverrideis one per-connection operator-supplied answer, read by the purereadStockLocationOverride(thereadStockSafetyBuffer/readPricingRuleconfig-coercion precedent) once per sync CALL rather than once per row, and applied byMasterInventorySyncServiceasinventory.locationId ?? override ?? nullbefore the row is written. Four properties are load-bearing. (1) Decision (2) is narrowed to the MASTER, not repealed - OpenLinker still never invents a location the master withheld and mints no synthetic DEFAULT location; a different speaker supplying the fact is not the sync guessing it, and a real adapter-reported location always wins, so the override becomes a silent no-op the day an adapter starts answering. (2) The effective location, not the adapter’s, feeds the #2322 tracking - an overridden position is “located” for the pooled-row repair exactly as a genuine one is, because from the repair’s perspective the two are indistinguishable. (3) Clearing the override is repaired too, and that is the half #2322 left open.markStaleExceptVariantsprunes per VARIANT, so the ordinary upsert re-creates and un-stales the pooled row while the abandoned located row stays live - andgetPromisableQuantitiessums across every location inglobalscope (#2321), so available-to-promise DOUBLES with every counter internally consistent, nothing thrown and nothing logged: an oversell invisible until a buyer hits it. That gap was unreachable while no adapter setlocationId; the override makes it onePATCHaway, somarkLocatedStaleForSource- the thirdisStalewriter, the exact mirror ofmarkLocationlessStaleForSource, same provenance rule, same no-updatedAt-bump raw statement, same absence of amaster.variant.staleemission (re-pooling is not a deletion, #1689), its owninventory.propagateToMarketplacesenqueue because the pooled row’s own write can legitimately no-op while the aggregate moves - ships with it, reported as the optionalMasterInventorySyncResult.locatedPositionsStaled. A variant reported BOTH pooled and located in one payload is SUBTRACTED from the mirror’s key set, or the two repairs would stale it from both sides and leave a live-position-less known zero that #1689 turns into paused offers for stock that is there. It is strictly the decision-(2) mirror and NOT multi-location pruning: a variant reported at ANY location is untouched, so a master dropping one of two locations still leaves the abandoned row behind. Unlike its mirror it costs one local indexed UPDATE on every ordinary pooled-only sync, accepted because the only way to skip it is a read that costs the same round-trip. (4) The id is validated on the TRANSITION, and must be ACTIVE -ConnectionServicecompares against the persisted config (the #2407 rule) and refuses an unknown id or one whose location isinactive, since a retired location is filtered out by routing and an override naming it would decide nothing. The standing degraded state - a location retired or deleted AFTER the override was set - is deliberately not enforced there, becauseconfigis replaced wholesale so every unrelated patch (includingAuthorityStatusService’s preset apply) re-asserts the stored value;deleteLocation’s referential refusal countsinventory_itemsrows and cannot see a config key, so closing that needs either a widened refusal there or an observable degradation at resolve time, and neither ships here. -
Operator controls, a zero threshold, and the cancellation hole (#2610): both
stockSafetyBuffer(#1844) andpricingRule(#1843) were implemented, tested and unreachable -apps/webcarried no field for either, so they protected and priced nothing. The connection Edit page now carries a platform-neutralStockAndPricingSectionbesideRateLimitSection(#1810), each group behind its own toggle and each stating its effect on a worked example rather than only taking a number, because a mis-set margin or reserve silently changes what buyers pay or how much you appear to have. Three properties are load-bearing. (1)0and unset are different persisted states and must round-trip apart - a typed0is the operator saying “no reserve”, writes a real0, and reads back as0- and because the form recommends exactly that,0is not treated as a probable typo byisPresentButInvalidStockSafetyBuffer, which would have had the product recommending a value and the log calling that same value suspect on every write-back; a cleared knob writes an EXPLICITnullrather than deleting the key, for the same shallow-spread reason asrateLimit(#2016), and every reader already treatsnullexactly like absent. (2) A margin of 100% or more is refused, not accepted and silently degraded:applyPricingRulefalls back to the base price there, so an operator who typed 120 would save happily and publish an unchanged price. The refusal is server-side as well as in the form, alongsidevalidateRateLimitConfig’s neutral bounds check inConnectionService.create/update- the form is not the only way in, since the raw JSON editor sits on the same page and bypasses the form’s ownsuperRefine, and curl and MCP bypass the browser entirely. The same neutral check boundsstockSafetyBufferandstockZeroThreshold. The browser cannot import@openlinker/core(#591), so the worked examples run a deliberate mirror (features/connections/lib/stock-and-pricing-preview.ts) kept byte-identical byscripts/check-stock-and-pricing-preview-mirror.mjsunderpnpm check:invariants- a preview computed by different arithmetic than the publish path is worse than none. (3)stockZeroThresholdis the second remedy the #1844 design named, for slow-moving stock: a quantity strictly below the threshold publishes as0. It is applied AFTER the reserve, to the quantity the destination actually sees, because the threshold is a statement about that number and not about the master’s own count;0disables it, so an existing connection is unchanged. It carries its own present-but-invalid guard (isPresentButInvalidStockZeroThreshold), because a mistyped threshold fails in the worse direction than a mistyped reserve - the destination sells the low stock the operator asked it to hide - and thelistingsstock-at-risk aggregate (#1983) resolves its “publishes nothing” predicate throughapplyStockSafetyBufferrather than the reserve alone, or it would under-report precisely the lines the threshold silenced. The same change closes a live defect on the cancellation path:OfferStockRestoreServicewrote the raw master count as an ABSOLUTE quantity and so wiped the reserve on the first cancellation until the next full sync. It now reproduces the same publish policy the ordinary write-back applies, which is also what keeps a threshold0a deliberate operator choice rather than a post-sale hold. -
A routed order lowers stock in the product master that owns each line, exactly once (#3453, epic #3460): with the OMS on, a routed order is HELD - it is never created in the product master, so the master’s own order flow (PrestaShop’s
validateOrder) never lowers its stock, and the last unit sold on Allegro stayed on sale in the shop and on every other channel. Each routing commit now enqueues oneinventory.saleDecrementjob per routed work (realtimelane, scoped to the ORDER’s source connection - never the holder every OMS-packed work shares, #2609), from both routing sites via the purederiveSaleDecrementEnqueueIntentsin thefulfillmentleaf. The worker handler reads the work (IFulfillmentWorkQueryService.findWorkById) and the order record and passes them intoIInventorySaleDecrementService, becauseinventorymay not importordersand core REPORTS the order’s attention verdict rather than writing it. Six properties are load-bearing. (1) The owner is resolved per LINE from the non-stale position’ssourceConnectionIdat the work’s location or pooled, never as one globalInventoryMasterlike the returns restock, because v1 allows two product masters and a mixed basket must lower each for its own lines only;NULL/'legacy'provenance and two owners for one line REFUSE rather than guess. (2) The Postgres claim is the guarantee, not the adapter’s key:inventory_sale_decrementscarries a UNIQUEidempotencyKey(sale:{owner}:{workId}:{lineId}) and the claim is written BEFORE the adapter call, so a replay, a re-poll or a retry never crosses the boundary twice - including against an adapter reportingidempotency: 'unsupported', whose own cache window is only a second layer. (3) An unknown outcome is never retried automatically. A claim found stillpending(a run died mid-call) and any throw from the write becomein_doubt; a blind retry risks decrementing repeatedly and silently, whereas declining risks one visible under-decrement. Everyin_doubtis paired with a best-effort re-read of the master’s stock, so the marketplaces show its real number whichever way the write went. Only a failure proven to precede the boundary (the owner’s adapter could not be built) isretryable- the one state a later run may re-claim. (4) A storefront sale is skipped, and the skip is persisted: a line whose owner IS the order’s source connection already lowered its own stock, and routing applies to everyOrderSource, so without the guard every shop sale would count twice (#3487 stops such orders being routed at all; this is the safety net). (5) Never OL’s location id at the adapter - the Subiekt adapter readslocationIdas amagazynId. After a successful write the master’s new quantity goes throughsetInventory, which enqueuesinventory.propagateToMarketplacesstraight away. (6) A refusal, an in-doubt line or a clamp is visible on the order, as the persistedstock-decrement-blockedattention state under the newsale-decrementproducer - level-triggered from ALL the order’s decrement rows, so a sibling work’s success never hides another’s failure. A clamp is detected as a sale larger than the stock OL had mirrored, since the master clamps at 0 rather than refusing. The routed order’s hold counts the sold units exactly once (#3480). The router is resolved ONCE, before the advisory hold, byresolveRoutingTarget()- the selection half of the intercept - and the same answer drives both the hold’s immutableatpEffectand the intercept, so they cannot disagree. An order OpenLinker is about to route is heldpublishedwhatever the ADR-012 dispatch routing says (its defaultomp_fulfilledwould stampdiagnostic, leaving marketplaces unaware until the decrement lands). The hold stays BEFORE the intercept on purpose: the intercept enqueues the decrement, and a hold created after it could land after its own consume and subtract for its whole TTL. The decrement service then consumes the line’s hold (closeForOrderwith the new optionalorderLineIds) when the line settlesapplied/deduplicatedor is skipped assource-is-owner, BEFORE the mirror write, so the propagationsetInventoryenqueues sees the hold gone and the master lower - the reduction moves from the hold to the master once, and the shortfall reconciler (#2349) has nothing to open. Ablocked/in_doubt/retryableline keeps its hold, so stock stays reduced while the retry runs. A failed consume on an already-settled line is REPORTED, not silently declared healed (#3491 review):consumeHold/consumeHoldIfSettlednever undo the decrement, which is already durable — they returnfalse, andDecrementForWorkResult.holdCloseFailedLineIdscarries the affected line ids into the handler, which throws a retryable error. That is what makes the next run’s replay path — the only path that can re-enter the (idempotent) consume against a settled row — actually reachable, rather than a heal the docblock claimed with no way to get there. Known limitation: a router selected and resolved that then REFUSES the plan mirrors the order to its destinations while its hold is alreadypublished; the stamp is insert-only, so that hold stays until the dispatch consume or expiry. -
A lost enqueue is backstopped (#3629): both producers swallow an enqueue failure by design and the job has no cron, so
inventory.saleDecrement.reconcileSweep(bulklane, every 15 minutes, default ON, global scope under the nil-UUID system connection, own lock) finds routed works on a live order for which NO decrement job was ever enqueued and enqueues one under the producers’ own idempotency key. The frontier is deliberately “no job insync_jobsunder that key” and not “a line with noinventory_sale_decrementsrow”: lines that legitimately never get a row (an item missing from the snapshot, a non-positive quantity) would otherwise be re-driven every tick for ever. A candidate leaves the set by being enqueued, so it is frontier-as-query with no cursor; the read joinsorder_recordsandsync_jobsby table name (ADR-036). The frontier carries a LOOKBACK floor (OL_SALE_DECREMENT_RECONCILE_LOOKBACK_MS, default 10 days - above the 7 day Redis dedup-key TTL, so a stalejobdedup:key cannot outlive the window - clamped 1 hour to 14 days) and a grace window (default 10 minutes):sync_jobsis pruned by #2946 (succeededrows after 30 days by default), so without a floor an old, correctly decremented work would reappear as “no job” the day its row is deleted, and on first deploy every work routed before the decrement existed would lower the master’s stock for an order long since handled. The clamp’s maximum stays below the retention floor so no setting can reopen that window. A job that was enqueued and then died is not covered - it is an operator-visible dead job. A candidate whose enqueue is deduplicated despite having no job row is a stale dedup key, not a race: it is warned (sale_decrement_reconcile_stuck_dedup_key) and counts toward the starvation log. -
Batched stock read (#2648, ADR-048 decision 1): the inventory sweep now enqueues one
master.inventory.syncBatchchild per PAGE instead of onemaster.inventory.syncFromSweepper product, andInventoryMasterPortgains the optionalBulkInventoryReaderprefetch rung - the same shape #2593 gave the catalogue sweep, deliberately copied rather than reinvented. It was the last per-product fan-out on a master path, measured at 100 requests per 100 stock positions against the catalogue sweep’s 5.8, i.e. ~10.4 days for one cycle of a 100 000-product catalogue. Four properties carry over unchanged and are load-bearing for the same reasons stated under Products. The rung is a PREFETCH, not a second sync path - void-returning, best-effort, and invisible except in request count, so the variant-keyed write (#822/#823), the #1904 rival-claimant guard and the #1688 deletion signal all keep running exactly where they do, unduplicated; a master declaring nothing, or one whose prefetch throws, gets the per-product reads it always had. The adapter is resolved ONCE for the page, which IS the mechanism:getCapabilityAdapterbuilds a fresh adapter per call, so a per-product child could never benefit from a bulk read however cheap. A failed product does not fail the page - the service reports failures and the handler re-enqueues each one as a per-productmaster.inventory.syncFromSweepjob under abatchRetrykey, so that job type stays registered and keeps its own retry ladder and dead row. And the child runs inbulk(#2594/ADR-050), neverrealtime. The PrestaShop implementation readsstock_availablesfiltered by a pipe-joinedid_productOR list with an explicitsort- a comma is a RANGE, which answers with a near-whole-catalogue page and nothing for the ids in the middle, and an unsorted offset read has no tiling guarantee at all; both are asserted against the string the real query builder emits rather than against a mock’s arguments. Two things this deliberately does NOT do: it does not raise the sweep’s budget or touch its cron (making the child cheap is the prerequisite, choosing what the run may then afford is a separate decision, taken with the same question for the deletion audit), and WooCommerce is not required to declare the rung. -
Routing cannot be enabled before a location exists, and the degraded state is REPORTED rather than enforced (#2407):
inventory_locations(ADR-058 decision 1, #2313) ships empty on every install with nothing seeding it, so switching fulfilment routing on against zero locations makes every line unfulfillable - a first run that reads as a broken feature rather than as an unconfigured one.ConnectionServicetherefore refuses thefalse -> truetransition with a 400 naming the remedy. Four properties are load-bearing. Enablement is decided from CONFIG, never from a capability list - the sourcing claim isconfig-onlyandFulfillmentRouteris deliberately absent fromCoreCapabilityValues, because a connection’senabledCapabilitiesis stamped at create and never retro-filled (#2085), so gating on a capability name would exempt every connection that already exists. The already-shippedparseAuthorityConfigis the only reader, which also means a config OpenLinker cannot read fails to MATCH rather than throwing: the coercer’s unrecognised-shape default is the frozen UNHELD claim, so an install that never opted in is byte-identical to its pre-#2407 self. The guard fires only on the transition, compared against the PERSISTED config (theenqueueInitialTaxonomySyncrule), so an unrelated patch on a connection that already claims routing is not refused for a state the guard itself let through - and it short-circuits on the claim before reading the database, so a connection with no claim pays no extra query. And it is enable-time only, not an invariant:deleteLocationand setting the sole location inactive can both take an already-enabled install back to zero, and enforcing at those writes would put a routing rule inside the inventory context, a cross-context edge ADR-053 forbids. The degenerate state is safe rather than corrupt - nothing routes, and orders fall back to today’s handling - so it is reported by the connection detail page’s routing-readiness panel, in those terms: routing is on and is currently deciding nothing, which is an unfinished setup step with a one-click remedy. The remedy isPOST /inventory/locations/bootstrap, which mints oneMAINwarehouse location idempotently - insert-then-recover on the unique code rather than a read-then-write, so two operators clicking at once cannot both create it, and a re-run reports the code inexistingCodesand creates nothing. It is an offer an operator takes, never a seed: nothing writes it from a migration or as a side effect of creating or enabling a connection, because minting on enable would make the refusal unreachable. The minted row locates no existing stock -locationId IS NULLon a position means the MASTER declines to locate its stock (ADR-058 decision 2) and nothing is ever assigned to that row on an operator’s behalf - so the panel’s ready state says the location still needs stock assigned rather than implying routing will now succeed. Since #3206 an operator may name it explicitly as a connection’sstockLocationOverride, which is a deliberate act on a different page rather than a stand-in this bootstrap creates for them. -
Ordered quantity writes per offer (#2617, ADR-067): the
realtimelane runs jobs concurrently and promises nothing about the order twomarketplace.offerQuantity.updatejobs for the SAME offer finish in, so an older quantity could land last and leave the listing advertising stale stock until the next change at the master - overselling when the older number is the higher one. Every propagation now carries the inventory row’s own write stamp (inventoryUpdatedAt, already minted byInventoryService.setInventoryfor the dedupe key) end to end asUpdateOfferQuantityCommand.observedAt, andInventorySyncServicerefuses a write whose observation is strictly older than the newest one already written for that offer. Four properties are load-bearing. The mark advances only AFTER a successful marketplace write, so a refusal always means a newer quantity is live on the channel - a newer write that failed cannot lock an older one out, which is what keeps the guard from being a way to leave a listing permanently stale. Only strictly older is refused: an equal observation is a job retry and is idempotent, and an unparseable value on either side passes, so a guard that cannot compare degrades to the pre-#2617 last-write-wins behaviour rather than dropping stock updates. A short per-(connection, offer)SyncLockPortlock wraps read-compare-write, because the comparison alone leaves two windows open - a non-atomic read-then-write of the mark, and two marketplace calls in flight whose arrival order at the platform OL does not control. Contention is reported as awrite_contendedfailure and the handler turns an all-contended result into a neutralContendedWriteError, which the runner defers penalty-free under the bounded deferral budget below - contention is the guard working, not the job failing, so it must not spend a retry attempt and eventually dead-letter the very stock write the guard exists to protect.advanceCursoris a plain set rather than a compare-and-set, so the lock is load-bearing for the MARK’s monotonicity too, not only for ordering the marketplace calls.observedAtis optional, so the stale-offer pause (#1689), which zeroes a listing on its own authority with no observation to quote, keeps writing unguarded, and a legacy job queued across the deploy behaves exactly as before. The mark lives in the ordinaryconnection_cursorsstore keyedinventory.offerQuantity.observedAt:offer:{offerId}- durable across restarts and shared by every replica with no new table, at the cost of one cursor row per mapped target per connection - never deleted, so a removed mapping leaves its row behind (an accepted leak, bounded by target count). The key saysofferbut also holdsShopProductexternal ids on the shop write-back branch, which is safe only because the namespace is per connection and a connection carries one mapping kind. The token must come from ONE clock, which is a precondition rather than a hope:observedAtisinventory_items.updatedAt, which #2071 made database-stamped and read back viaRETURNING, so cross-process skew does not exist and the only ambiguity is two writes inside the same millisecond, which the rule lets through. Replacing that stamp with a worker-sidenew Date()would let a process running ahead refuse a genuinely later write and leave the channel stale - worse than the last-write-wins this replaced. The guard costs no extra marketplace call - an observed batch is guarded PER ITEM and the survivors still go out in oneupdateOfferQuantitiesBatchcall, with only the ids the adapter reported as succeeded advancing their mark - and it does not depend on lane serialisation - which matters because #2609 removes the accidental single-slot ordering thefan-outlane provided. Narrowed by #2621, ADR-067 § Amendment (#2621): onceAllegroOfferManagerAdapter.updateOfferQuantityreturns on Allegro’s async ACCEPT rather than a terminal status, “a successful marketplace write” for Allegro means submitted, not applied - a submission that later fails asynchronously has already advanced the mark, with no automatic re-drive (the reconcile pass’s'failed'outcome only records the status; the inventory sweep is the only backstop), and the lock only orders submission, never Allegro’s own application order for two already-accepted commands. The cancellation restore uses the same guard (#3814 review):OfferStockRestoreServicetreatsOfferStockRestoreras a participation declaration only and writes the restored quantity throughIInventorySyncService.updateOfferQuantities, quoting the variant’sstockUpdatedAtasobservedAt, because a restore written straight to the adapter could land after a newer propagation and roll the listing back. Its idempotency key carries the order, the quantity and the observation, so a retry after stock moved is a new command and never shares one with an ordinary propagation. -
Connection-ownership guard on the inventory staleness prune (#1904):
MasterInventorySyncServicewithholds both its post-pull prune and its full-deletion prune when a second connection withInventoryMasterenabled claims the same internal product id - see the identical guard described under Products above (one sharedIEntityClaimServiceseam, one capability value per context). -
Locations CRUD (#2316):
inventory_locations(#2313) gained its operator-facing API —GET/POST /inventory/locations,GET/PATCH/DELETE /inventory/locations/:id, admin-only for every write.codeis normalised (trim + uppercase) in exactly ONE place, the application service, because the unique index is case-sensitive; it is deliberately absent from the PATCH body, since the natural key is not a patch-shaped field. A delete is REFUSED (409,LocationInUseError) while anyinventory_itemsrow still names the location — the refusal lives inLocationService.deleteLocation, not the controller, becauseinventory_itemscarries no FK toinventory_locations(ADR-058 decision 3 defers that to step iii) so nothing in the database refuses it and a controller-only guard would protect the HTTP caller alone. Retire a location withstatus=inactiveinstead, which keeps historical positions pointing at a row that exists. -
Duplicate-position detection (#2319, ADR-058 ladder step (iii)):
GET /inventory/duplicate-positionsis a READ-ONLY, admin-only operator diagnostic — it detects, never repairs, and writes nothing. It groups everyinventory_itemsrow by the four-column position key (productId,productVariantId,locationId,sourceConnectionId) and reports the keys holding more than one row; provenance is part of the key because ADR-058 decision (2) makes cross-source coexistence legitimate. It is the gate procedure for #2325 (SET NOT NULLplus folding provenance into the position key), and must be run AFTER the #2317 backfill has drained — the full procedure, the survivor rule and the remediationDELETElive in docs/operations/inventory-duplicate-positions.md. -
See ADR-061 for OL-owned advisory reservations and the
AvailabilityAuthoritycapability. -
Recording a hold —
IReservationService(#2344, ADR-061 decision 1, design § 4.2 amendments 1/2): the order-shaped seam over #2343’s ledger, called byOrderIngestionServiceafterpersistOrderand before destination provisioning. It resolves each line’s variant to aninventory_itemsposition, applies two gates, and hands every claimable line toclaimHeldin ONE call — the sort-by-inventoryItemIddeadlock guarantee, the single transaction and the all-or-nothing rollback are all properties of that one call, so a per-line loop forfeits all three. Six properties are load-bearing. (1) Get-or-create, never reject-on-retry:quantityis the DESIRED TOTAL and a repeated claim is a SUCCESS withdeltaApplied: 0, which is what makes an ingestion crash after the claim resumable instead of wedging the order behind a false “insufficient stock”; there is deliberately no availability pre-read — the check IS the reserve, and an unlocked read-then-act is the shape § 6I replaces. (2)atpEffectis stamped by the CALLER and is immutable per reservation, so #2345’s ATP subtraction is a local column test and noinventory ↔ fulfillmentread exists on the publish path. Ingestion resolves it fromIFulfillmentRoutingService:omp_fulfilled(rule or default — the marketplace ships) ⇒'diagnostic',ol_managed_carrier/source_brokered⇒'published'. Two arms take'diagnostic'for one reason — aprocessorAvailable: falserule and an unresolvable routing read both mean OL cannot claim it executes this order, and over-subtraction is the harm ANALYSIS-1032 calls worse than shipping nothing. Resolving routing insideinventorywas rejected (A1): it would recreate the very edge decision 1 removes, at write time instead of read time. (3) The multi-position gate rejects loudly: a variant resolving to more than one live position with no explicitinventoryItemIdraisesAmbiguousReservationPositionErrorbefore anything is written —findAvailabilityByVariantIdsSUMs positions while a reserveUPDATE … WHERE id = $1takes one, so a silent pick is an oversell with every counter internally consistent. The error is plural and exhaustive (it names every ambiguous line at once) so the call site degrades in a single retry that drops them, never a loop and never the loss of the order’s other holds. (4) A terminally-closed line is never re-held. The idempotency index is PARTIAL (WHERE status = 'held'), so areleased/consumed/expiredrow does not block a fresh insert — and ingestion re-runs on every re-poll, so unguarded, a shipped order would mint a new hold each poll and re-increment the counter for stock that already left, while #2346’s sweep and a re-poll would fight in an unbounded resurrection loop. The service therefore reads the order’s rows (all statuses) and skips those lines with reason'already-closed'. That read is not the forbidden read-then-act: it asks a lifecycle question over MONOTONE state (nothing returns a terminal row toheld), not a quantity the guard must decide atomically. (5) Zero live positions is REPORTED, not thrown ('no-position') — a variant noInventoryMasterhas synced legitimately has no position, and ANALYSIS-1032 names the throw a defect producing “a permanent domain rejection of a real, paid order”; ambiguity is the one case where continuing means guessing, which is why only it refuses. (6) The ingestion call is best-effort and never rethrows — the order is the fact, the hold is OL’s accounting of it, andInsufficientAvailabilityErroris the routine refusal of an oversell rather than an invariant violation, so failing the job would burn the retry ladder against a condition retrying cannot change. This deviates from § 6I’s “roll back the whole order’s set” deliberately; naming the shortfall ON the order is #2349’s, and until then the signal is an error-level log.expiresAtis mandatory (ADR-061) and defaults tonow + OL_RESERVATION_TTL_MS(7 days, clamped[1 h, 90 d]), honoured — likeatpEffect— only on INSERT. Release, consume and expire land on this same interface with #2346 / #2347 / #2348.OrdersModulenow importsInventoryModule, a one-way edge in theOrdersModule → InvoicingModuleshape; the standing invariant it creates is that nothing reachable from the inventory barrel may top-level value-import@openlinker/core/orders, proved on a real container boot byapps/worker/test/integration/invoicing-auto-issue-boot.int-spec.ts. -
Available-to-promise is a seam with PROVENANCE (#2321, ADR-061 decisions 1/2/3): four shipped sites each computed a publishable quantity themselves —
InventorySyncService’s write-back, both listing builders, and the stock-at-risk read — so the buffer rule lived in four places and there was nowhere for a reservation term or an external authority’s answer to arrive.IAvailabilityService(libs/core/src/inventory, ADR-052’s A1 row assigns availability to this context) is the one place that answers “how many may we promise for this scope”, returning aPromisableQuantitywhoseprovenanceis the point:'computed'from OL’s own mirrored positions,'authority'once a dispatched adapter answers (declared, Wave 3, never produced here), or'unknown'— which callers must treat as suppress the publish and alert, never as0, since an absent number silently written as zero is how a healthy catalogue stops selling. The formula ismax(0, Σ available[live, all locations AND sources] − Σ olReserved[atpEffect='published'])with the operator’sstockSafetyBufferapplied last as a Control (decision 3), and three of its properties are load-bearing rather than incidental. Summing across sources is correct, not a duplicate-counting bug (ADR-058 decision 2) — two positions differing only in owning connection are legitimate coexisting mirrors, and deduplicating them here would silently move published quantities on a healthy multi-source install; that is #2319/#2325’s subject.inventory_items.reservedQuantityis never subtracted — it mirrors the master’s own bookkeeping and is already reflected inavailableQuantity, so subtracting it would double-count. And the ledger term ships empty rather than absent:ReservationLedgerReaderPortis called with a REQUIREDatpEffectargument (never defaulted — the wrong default subtracts diagnostic holds from a real quantity) againstEmptyReservationLedgerReader, so every answer is byte-identical to what the four sites publish today and Wave 2 becomes one provider-binding swap rather than a formula change under four live callers. Scope is the leaf’s discriminatedAuthorityScope, aliased inventory-side so the zero-sibling-edge leaf never learns an inventory-flavoured spelling of its own type exists;channel(the publishing shape) andglobalare answered, whilelocation/order/workthrow rather than degrade to an unfiltered number — ignoring a location filter is correct on a single-warehouse install and oversells the day a second one appears. A variant with no positions at all is'computed'0, deliberately not'unknown': #1844 makes the master authoritative including zero and #1689 zeroes offers precisely so a deleted product stops selling, so routing that case to a suppressed publish would keep it selling. Consumed by nobody in this slice — the #2304 “vocabulary ships first” posture; rewiring the four sites onto the seam is #2323, which checks itself against the parity fixture this slice exports. -
ATP subtraction is SCOPED, and what it does not subtract is reported (#2345, ADR-061 decision 1, design §4.2): the ledger term #2321 shipped empty is now real -
RESERVATION_LEDGER_READER_TOKENbindsReservationLedgerReader, which sumsreservations.quantity WHERE status = 'held' AND "atpEffect" = $stamp, joined toinventory_itemsand grouped by the position’sproductVariantId. Five properties are load-bearing. (1) The stamp is a bound column test, never an inference -atpEffectis written at creation by the ingestion caller holding the routing outcome (#2344) and is immutable, so adiagnostichold is invisible to apublishedread BY CONSTRUCTION and cannot affect a published number under any configuration. That is the answer to §6I’s original kill condition and is deliberately not configurable. (2)isStale = falsemirrors the numerator:findAvailabilityByVariantIdsexcludes stale positions (#1478), so a hold against a staled position must be excluded too - subtracting it from a total that never included it silently under-publishes the variant, and on a fully-staled one would clamp a known zero to a reason-less zero. (3) Onlyheldrows count - terminal rows are kept forever (#2343), so a predicate keyed on row existence would subtract every release the system ever performed. (4) OL subtracts its own ledger ONLY for scopes it computes itself.applyScopedLedgerSubtraction(availability.types.ts, besidecomputeAtp) takes a discriminatedAtpAnswer: thecomputedarm subtracts, and theauthorityarm passes the answer through untouched and reports OL’s holds as the newPromisableQuantity.olHeldNotReflected. Subtracting there would double-count - an authority that models holds has already netted its own, and one that does not is stating a number OL has no standing to reduce. The authority arm is declared and never produced in Wave 2 (no dispatchedAvailabilityAuthorityexists), so it is asserted at the pure-rule level rather than through the service;AvailabilityServicealways passes'computed'and names the single Wave-3 flip point in place. The buffer applies on BOTH arms, because ADR-061 decision 3 makes it a Control on top of any promise rather than part of one. (5)olHeldNotReflectedisnullon the computed path, never0- the holds ARE insidequantitythere, and0would say “no outstanding holds”, a different and usually false claim; on the authority path0is meaningful and means OL holds nothing the authority does not already know about. The reader refuseslocation/order/workas well asAvailabilityServicedoes, and the repetition is the point: reservations carry no channel axis (a hold is a claim on physical stock, soglobalandchannelshare one sum), and a future caller reaching the reader directly must not receive an unfiltered whole-catalogue sum dressed as a location-scoped one. #2321’sEmptyReservationLedgerReaderis no longer reachable from any production path - it moved to@openlinker/core/inventory/testingas the zero-ledger fixture the parity matrix needs, and must never be bound in a module again, since binding it would switch ATP subtraction off silently. Story I1 (an install with zero reservations publishes byte-identically) is unchanged and still asserted end-to-end byapps/api/test/integration/publish-quantity-parity.int-spec.ts; the predicate itself is proved against real Postgres byatp-subtraction.int-spec.ts. -
Expiry is STATE-DEPENDENT, and with no obligation source it releases nothing (#2346, REVIEW § 3 C1, design § 4.2 amendment 3):
inventory.reservations.expireexamines held reservations pastexpiresAtand extends whenever a live OL-executed obligation cannot be RULED OUT, releasing only on a positively confirmed absence. A naive sweep releases a fraud-held order’s reservation, republishes stock that is still promised, and the later dispatch oversells - silently, with every counter internally consistent, so nothing alerts.releaseHeldis the only thing that stops a hold counting (#2345 filtersstatus = 'held'), which is exactly why the obligation check gates the RELEASE and not some later publish;atpEffectis never rewritten, since extension movesexpiresAtalone and rewriting the stamp would move a published quantity with no audit trail. Five properties are load-bearing. (1)order_holds(#2339) does not exist on this branch, so the only obligation kind answers'indeterminate'unconditionally (UnavailableOrderHoldReader) and the pass ships inert with respect to release: every candidate is extended and nothing is ever released. Two mechanisms keep that from rotting into a pass that merely READS as working -ObligationReadersis a MAPPED TYPE overReservationObligationKindValues, so Wave 3 addingaccepted-fulfillment-workcannot compile until a reader exists, and a spec asserts no reservation is ever released while the placeholder is bound, which makes forgetting to swap it safe and swapping it deliberate. When #2339 lands the ONLY change is binding a real reader, which must answer'absent'only on a positively confirmed absence - never as a default or as the fallback arm of a failed read, the one shortcut that converts fail-closed into a silent oversell. The fold ispresent > indeterminate > absent: a reader that cannot answer is never outvoted into a release, and a REJECTING reader folds to'indeterminate'rather than propagating, so one unavailable source degrades the sweep to “extend” instead of aborting a run that could still safely extend its page. (2) It is deliberately NOTrunBoundedSweep. The candidate set isstatus = 'held' AND expiresAt < now- frontier-as-query, since every page CONSUMES its own selection (a released row leaves the set, and an EXTENDED row leaves it too becauseexpiresAtmoves forward), so an advancing offset over a shrinking set steps over holds silently, which here means a hold that never expires and stock that never returns.bounded-sweep.tsdraws that distinction in its own header andinventory-provenance(#2317) recorded the identical reasoning; the sweep PRIMITIVES are reused (budget clamp, lock TTL) and the offset machinery is not. NoMasterSweepKindmember is added - that union is master-prefixed andsweepLockKeyrendersmaster:{kind}:sweep:{id}, a false name for a pass with no master - so the handler owns its own lock key and needs no cursor at all. (3) A second, AGE bound is not optional. Fail-closed means indeterminate ⇒ extend, and with no obligation source that is true forever, so without it theheldset never drains and the stuck state is invisible. PastOL_RESERVATION_OBLIGATION_MAX_AGE_MS(30 days, from the hold’s owncreatedAt- the #2330 precedent, no column and no migration) the sweep still extends and still never releases, because no amount of elapsed time makes a possibly-promised unit safe to republish; what changes is that the hold is counted on the job result and logged. (4) The escalation is a counter plus a greppable log, NOT a persisted operator fact - W2-15’s needs-attention reason set does not exist on this branch, and emitting a fact into no sink would make an unhandled condition read as handled. (5) A wholly failed page is reported, because candidates are ordered oldest-overdue-first and a row leaves the set only by being written: one that fails permanently keeps itsexpiresAt, stays at the head, and is re-read every tick, so enough of them fill the page and starve the rest. A scan offset - #2330’s answer to the same shape - is unavailable here for the reason in (2), so the condition is surfaced (reservation_expiry_page_all_failed) rather than worked around. Global scope under the nil-UUID system connection (reservations key on(order, line, position)and carry no connection axis),bulklane (nothing a buyer waits on, and a hold examined a tick later is a hold that stayed held - the safe direction), default ON. AC-2’s limitation is stated rather than papered over: “an expired reservation with no obligation is released and republished” is assertable only through a test seam that injects the predicate, and noorder_holdsrow is faked to make it look end-to-end. -
Cancellation releases the ledger BEFORE it restores, in one method (#2348, ADR-028 repointed onto ADR-061): the ADR-028 stock restore predates the ledger, and nothing in the tree released a cancelled order’s holds -
releaseHeldhad exactly two callers, the expiry sweep and the shipment consume - so a cancellation left its hold standing and #2323’s repointed restore published an ATP still net of it, i.e. a live offer under-selling by exactly the cancelled quantity, permanently and with nothing in any log to say so.OfferStockRestoreService.restoreStockForCancelledOrderis now the whole ordered sequence: release the order’s holds (IReservationService.closeForOrder({terminalStatus: 'released'})), then restore. Release is what MOVES the number - #2345’s ATP read filtersstatus = 'held', so a released row leaves the subtraction immediately - which is why the ordering is a real ATP transition rather than bookkeeping. Five properties are load-bearing. (1) The ordering is structural, not conventional: the private restore step takes the release’s ownCloseForOrderResultas its first parameter, so the dependency lives in the SIGNATURE rather than in a comment and is hard to invert by accident. Its honest limit (#2628 review) is thatCloseForOrderResultis a structural type, so an object literal of the same shape type-checks and a determined reordering still compiles - the signature is not a compile-time proof, and the thing that actually PINS the order is a shared-recorder spec asserting the two effects in sequence, which also catches a refactor that preserved the types but inverted the effects. Two handlers with a documented order would have given neither. (2) Release runs ABOVE theOfferStockRestorershort-circuit, unconditionally. Most connections expose no restorer (Allegro gained one with E2E-24: it does not put a cancelled purchase’s units back into the offer by itself, contrary to what this assumed), so a release placed behind that guard would leak a hold on every one of them. (3) A cancelled-after-dispatch order does not restore, keyed on the durableShipment.reservationConsumedAtclaim through the newIShipmentQueryService.hasConsumedReservations- a DURABLE FACT, never an inference from reservation status, which cannot tell “consumed” from “never reserved” becauselistHeldByOrderRecordIdreturns held rows only. Per the issue’s own assumption the contradiction is DISPLAYED (story L6), not reconciled by republishing. (4) An incomplete release FAILS THE JOB.closeForOrdertolerates per-row failure by counting it - correctly, inside the ledger close - but that tolerance does not belong at the orchestration seam: live holds still standing means publishing would under-restore, and returning{outcome: 'ok'}would retire the work forever, sinceSyncJobRunnerretries only a FAILED job and there is nostockRestoreSweepreconcile task the way #1689’s pause has one. It raisesOfferStockRestoreReleaseIncompleteErrorinstead and the whole idempotent sequence goes back on the retry ladder. (5) The sequence has NO claim marker of its own, and that is what makes it crash-safe rather than merely throw-safe. The release’s terminal status IS its record and is the same fact the ATP read consults; the restore is an ABSOLUTE set, never a delta. So aSIGKILLanywhere leaves the job un-succeeded, the retry re-runs the whole sequence, the release closes nothing (guarded onheld) and the restore republishes the same number - the only state a kill can leave is a MISSING restore, the safe direction (under-published, never over-published). The method now returns an outcome rather thanvoidso every non-restoring exit is observable; an unknown availability is still OMITTED and never written as0, which is the primitive #1689 uses to pause an offer.ListingsModuleimportsShippingModulefor the marker read - acyclic, andshippingmust never importlistingsback.
4. Orders
Section titled “4. Orders”- Responsibility: Order ingestion, synchronization, and lifecycle management
- Key Entities: Order, OrderMapping, OrderStatus, IncomingOrder
- Location:
libs/core/src/orders/ - Capabilities:
OrderSourcePort— cursor-based order-event ingestion from marketplaces and shops (listOrderFeed+getOrder({externalOrderId}))OrderProcessorManagerPort— destination-shop order creation (createOrder, the only base-port method); post-create status/tracking and lifecycle reads live in composable sub-capabilities (OrderFulfillmentUpdater,OrderStatusWriteback, …). The canonical OL-owned lifecycle state machine is deferred (#1032).
- The lifecycle phase is DERIVED, and projects one-way onto the status (#2305/#2307, ADR-059):
OrderStatus(pending|processing|shipped|delivered|cancelled|refunded) stays the transport vocabulary forOrderCreate/OrderFulfillmentUpdaterand is unchanged. Alongside it, theorder-lifecycleleaf (§ 21) owns a derivedOrderLifecyclePhasecomputed from facts by the purederiveOrderLifecyclePhase; it projects one-way onto the transport status for writeback viaphaseToOrderStatusand never reads back from it.order_state_mappings— the operator-configured destination status translation — remains a transport-layer translation and never feeds the derivation. Stating the direction explicitly is the revert precondition ADR-043 failed to meet: a phase that could be read back from a destination-translated status would let a marketplace’s vocabulary silently redefine OL’s own lifecycle. - The holds API, and the release that re-starts provisioning (#2341):
POST /orders/:internalOrderId/holdsandPOST /orders/:internalOrderId/holds/:holdId/release, both@Roles('admin')— core has no roles, so the admin question is the interface layer’s and is answered here rather than inOrderHoldService. Four properties are load-bearing. (1) The rich projection is DETAIL-ONLY.activeHold+holdHistory[]are attached bygetOrderalone, never by the sharedtoDtothat runs per row on the paged list, becauselistHoldsis one query per order and on a list that is an N+1;activeHoldis DERIVED from the samelistHoldsread rather than a secondgetOpenHoldcall, so the two answers cannot disagree. The list is not left blind — the sharedtoDtogains the single scalaractiveHoldReason, which is free because #2340’s column is already loaded, and it is what #2342’s row badge renders. That column is a display cache with an hourly repair window: a badge may render it, and no GATE may read it — whether an order is held is decided throughIOrderHoldService.getOpenHoldagainstorder_holds, which is the epic’s L4 exit criterion rather than a preference. (2) Both 409s carry a distinguishable machine-readable code (ORDER_ALREADY_ON_HOLD/HOLD_ALREADY_RELEASED): the two states have different remedies and a status code alone cannot tell an operator which one they are in.HoldReleaseNoteRequiredErrormaps to 400 (resubmit with a note — a request problem, not a permission one) andHoldReleaseNotPermittedErrorto 403, the latter unreachable from a route whose actor is always a user but mapped so a future service-actor route cannot fall through to a 500. (3) The ownership check precedes the write.releasetakes only aholdIdwhile the route also carries an order id, so the controller reads the order’s holds first and 404s on a foreign id with no release attempted — releasing and then 404-ing would perform a side effect on the refusal path, leaving a hold released while telling the caller nothing happened. That pre-read decides only WHICH refusal; a concurrent release inside the window is still caught as the 409. (4) Releasing a hold re-enqueues the provisioning run it was suppressing, and the outcome is REPORTED rather than assumed. This closes the gap #2339 stated and left: the gates are re-entrant, not self-driving, and a cursor-based source journal (Allegro) never re-delivers the original order event, so without this a released order sat un-provisioned indefinitely.IOrderProvisioningResumeServicelives inOrdersModulebesideOrderDestinationRetryService— whose three seams (record repository, identifier mapping, job enqueue) it shares — and deliberately NOT in the leafOrderHoldsModule, whose whole purpose is to take one repository and nothing else. It never throws for a modelled condition: the hold is already released when it runs, so a throw would answer 5xx for a release that DID happen and send the operator into aHoldAlreadyReleasedErrorretry. Instead it returns a discriminated result the response carries —enqueued,skipped(an order with no source-external-id mapping has no source-side job to run and is healthy, not failed), orfailed. The failure arm carries a stable CODE, never the caught message: an enqueue failure surfaces from Redis / Postgres / TypeORM and those messages routinely carry a host, a port, sometimes a credential fragment, so the message is logged and the code is returned. Reporting rather than swallowing matters becausemarketplace.order.synchas no cron backstop for one specific order — a lost enqueue is an order that stays un-provisioned until something unrelated re-polls it, and the operator’s remedy is the existing destination Retry action. It writes nothing: #2339 already persists a held skip aspendingWITH the reason, so there is no slot to claim and re-flipping would erase that reason text. In the same changeOrderHoldRepositoryPort.listOpenHoldswas deleted rather than re-documented — it had zero callers and its docblock advertised an offset-paged read for “#2340’s reconcile sweep”, which is false: #2340 rejected offset paging over a shrinking open set (it steps over rows) and built a frontier-as-query instead, so leaving the method would have left that trap on the port for the next reader to reach for. /orderslist search, packed/open-return/tag filters, internal notes and tags (pilot epic #3507, G03):order_recordsgains a denormalized, diacritic-foldedsearchTextcolumn (deriveOrderSearchText, theDestinationCategory.searchTextprecedent — order number, buyer name, buyer email, every line SKU, GIN trigram index) plus a?search=filter onGET /orders/GET /orders/count; a tracking-number query widens the same predicate via an OR-list of order ids resolved fromIShipmentQueryService(ordersmay not importshipping, which already depends onorders, so the resolution happens in the API interface layer, not in core).packed(#2997 —packedAt IS [NOT] NULL, the acceptance criterion #2072 shipped everything except) and?tag=/?untagged=(#3532, a same-context join against the neworder_tags/order_tag_assignmentstables, since both live inorders) round out the filter set — all ANDed withhealth, never a sixthOrderHealthbucket. The “open return” badge + filter (#2998) reuses the/returnslist’s ownall_opensegment predicate verbatim via two newIReturnsServicereads (listOpenReturnOrderIdsfor the filter, batchedgetOpenReturnSummariesForOrdersfor the Status-group badge) rather than redefining “open” a second time.order_notes(+ an append-onlyorder_note_revisionstable capturing each edit’s pre-edit text, so “an edited note keeps its previous text” holds after the overwrite) andorder_column_presets(personal, plus oneuserId IS NULLadmin-set workspace default) round out the epic’s backend;BenchParcelViewgains exactly one field,packerNotes— notes flaggedshowToPacker(D12), read only, never a tag. A recovery pass over the same epic adds:order_notes.pinnedAt(at most one pinned note per order, a partial unique index oninternalOrderIdcleared-then-set in one transaction, author-or-admin — the D33 shape extended to pin); the--tag-*design-token vocabulary (apps/web/src/index.css, eight colours) plus the tag picker, header/list chips, a Settings tag manager (/settings/order-tags, admin-only rename/recolor/delete) and the bulk “Tags” action; andorders.export(#3534, D35) — abulk-lanesync_jobstype whose handler (OrdersExportHandler) reads the run’s scope/filters/columns throughIOrderRecordService(never the repository port directly, the cross-context rule), builds a CSV (pure, in@openlinker/core/orders) or an XLSX (exceljs, worker-only dependency) from one shared column-resolution function (resolveOrderExportCell), and writes the result inline as base64 onto its ownorder_exportsrow (theinvoice_records.sourceDocumentStoredDocumentpattern — no new file-storage port).orders:exportis a new admin/operator-only permission (D35: an export takes buyer data out of the system, soorders:readalone does not imply it). TwointernalOrderIdcolumns were mistypeduuidinstead oftextin the FIRST pass of this epic (order_notes,order_tag_assignments) — an internal order id isol_order_{uuid}, never a bare uuid, matching every other reference to it in this context (order_holds,order_changes,refund_records); corrected in both the migrations and the ORM entities before either shipped.packedAtis a display fact, not a gate (#2072/#2080):order_records.packedAtrecords that someone marked the order packed (OL’s own bench or an external WMS through the packed endpoints, called as anoperatorservice-account user). Nothing decides on it today, so no OL-vs-external exclusivity exists; a feature that gates onpacked(a dispatch block, an automatic status transition, an SLA) must design that exclusivity first. It may later be derived from lines (#2080) without becoming a rival to them.- Router-filtered destination fan-out (#2397, design §5.5):
OrderSyncServiceis retained, not reinterpreted under a fulfilment router — destination-mirror creation is a commercial/catalogue act, distinct from fulfilment assignment — so the fan-out is merely NARROWED, by an optionalOrderSyncRequest.destinationConnectionIds. The field carries three states that are never collapsed into two: absent (no routing answer — unfiltered fan-out, exactly what every install does today), present and non-empty (narrowed to these ids), and present and EMPTY (a router that positively selected nobody). A caller with no routing answer MUST omit the field and must never pass[]; getting that backwards silently stops destination provisioning everywhere. Note the deliberate asymmetry with #2393’sassertRoutingPlanResolved, which raises on an unrecognised plan status because that is a programming error — an absent router is the normal state and passes straight through. A deliberate empty decision returns[]and does not throw:MarketplaceOrderSyncHandlerwraps a throw inSyncJobExecutionErrorandisNonRetryableErrorconsults the PER-PLUGIN retry-classifier registry, where a coreordersexception is registered nowhere and is therefore retryable — so throwing would re-run the whole ofsyncOrderFromSource(a live marketplacegetOrder,persistOrder, identity resolution, projection updates) ~10 times and end in a dead job whoselastErrorblames connection configuration for what the ROUTER chose. Returning[]makes ingestion’sresults.map(...)a no-op, matching the reasonskipped_cancelled(#2284) andskipped_held(#2339) are distinct arms: “nothing went wrong” must not be routable into a retry. BecauselistCapabilityAdaptersis active-only, an empty destination list now conflates three conditions, told apart at the branch: (a) nothing configured — throws unchanged, the exception carrying no ids; (b) the router chose nobody — warn +[]; (c) the router named ids none of which is eligible — throws, naming them onNoOrderDestinationsAvailableException.unresolvedDestinationConnectionIds. Source-echo ids are excluded from that set because source exclusion runs BEFORE the filter, so an id naming the source was dropped by design and is not an unreachable connection; a decision naming ONLY the source gets its own message rather than blaming a working connection. (c) is a genuine new regression, recorded rather than discovered later: an order whose single routed destination is momentarily disabled becomes a total failure where an unfiltered fan-out would still have reached its siblings — it throws rather than returning[]because the router asked for a destination OL could not reach, and the retry that follows is appropriate since a disabled connection can be re-enabled. #2704 keeps that throw and closes the half it hid: the branch was only ever reached when NOTHING resolved, so a decision naming[a, b]of which onlybwas eligible proceeded tobalone behind a singlelogger.warn— an order silently under-provisioned, which is the same defect one condition over.resolveDestinationsnow reports its unresolved ids whether or not any resolved, so the partial case is persisted asrouted-destinations-partially-unavailablerather than logged. The fan-out is deliberately NOT failed for it and no fallback is invented: a routing decision is a REQUIREMENT rather than a preference, so mirroring to a destination the router did not choose would be a shipment OL was never asked to make. And (b) carries a known operator-facing cost: nosyncStatusrow is written (which is what the filtered-destination contract requires), so on/ordersa routed-to-nobody order is indistinguishable from one with nothing configured — both render “No destinations” (rollup.total === 0 -> 'unknown'), the same reading ingestion already adjudicated as the worst option for #2339. (a) and (b) were therefore distinguishable at the branch but NOT on the operator surface — the follow-up is #2703, below. The filter is safe because nothing downstream derives correctness from asyncStatusrow: for routed ordersfulfillmentStateis fed by work-progress-derived shipments throughShipmentDispatchService, and the reservation publish-subtraction never consultsfulfillmentStateat all (design §3 adjudication #1). No cross-context edge is created in either direction — this service resolves no router and imports nothing from@openlinker/core/fulfillment; the ids arrive as caller-supplied data, so ADR-053’s no-injection invariant holds for free rather than by discipline, andFulfillmentRouterstays absent fromCoreCapabilityValues(#2403). Populating the field at ingestion — thenone / ambiguous / selecteddecision — is #2400’s. - A routing narrowing is PERSISTED on the order, never only logged (#2703 / #2704): the three conditions #2397 told apart at the branch reach
/ordersasorder_records.destinationRoutingBlockReason+destinationRoutingBlockDetail, so an operator can tell a working router making a decision from a named destination OpenLinker could not reach. Before this both rendered “No destinations” — identical to an install with nothing configured — which is the reading ingestion had already adjudicated as the worst option for #2339. Six properties are load-bearing. (1) It is the #2100 persisted-reason shape, not asyncStatusarm. AsyncStatusrow is keyed bydestinationConnectionIdand the entire content of therouted-to-no-destinationcase is that there is none, so there is no id to key a row on; a new arm is also exactly what a consumer could route into a retry, which is the property #2397 bought and must keep; andOrderSyncStatusFilterValuesis the documented source of truth forGET /orders?syncStatus=, so widening it would widen the public query vocabulary for a value no real destination row can carry. (2)routed-to-no-destinationis a DECISION and is never counted. It renders a neutral per-order badge - on the/ordersrow and mobile card (one sharedOrderDestinationRoutingBadge, in the Status group beside health) and on the order detail page - and is excluded fromDestinationRoutingAttentionReasonValues— derived by.filterfrom the full list rather than hand-written — because on an install that legitimately routes some orders nowhere it would be true of a large permanent population, which is precisely how #2100’strigger-model-manualput a red “Invoicing blocked 4,312” on a healthy install. The aggregate counts against an explicit IN-list built from that array, neverIS NOT NULL, so a reason written by a newer release and rolled back is not counted either. (3) The count is ANDed besidehealth, never folded into it.OrderHealthValuesis a partition whose values sum to the KPI cards, so a sixth member would double-count or hide a sync failure behind a routing one —destinationRoutingBlockedis a non-partitioning field and filter chip, the shapesalesDocumentBlockedandtaxRateConflictalready have. (4) Arm (b) still returns[]. The write happens BEFORE all three branches, at the single point where the observation exists, so persisting a reason never converts a deliberate empty decision into a throw. The write costs one statement persyncOrderon every install, router or not (anIS DISTINCT FROM-guarded UPDATE that changes nothing, and bumps noupdatedAt, when the value is unchanged), which is accepted while the feature is latent. (5) The write is level-triggered and cleared from the held path too. The purederiveDestinationRoutingBlockanswersnullwhen routing narrowed nothing, and the writer stores thatnull, so a fixed router clears the reason on the next sync; becauseinterceptFulfillmentRoutingHOLDING an order meanssyncOrderis never called,OrderIngestionServiceclears the reason on its held branch as well, or a stale routing reason would sit beside a freshfulfillmentBlockReason. Both columns are omitted from the repository’stoOrm(thecancelledAt/ #2100 single-writer precedent) and written by one narrow guarded UPDATE, sopersistOrdercannot null-then-reset a value a peer transition just wrote, and the reason is coerced on read so an unrecognised value reads as absent. (6)detailcarries ids and counts only, bounded. The ids come from a routing decision OpenLinker does not author and the string is persisted and rendered verbatim, so it names at most ten and always states the total rather than interpolating an unbounded payload into atextcolumn; it carries no buyer data. The reason vocabulary is mirrored inapps/web(which cannot import@openlinker/core, #591) with one copy source per value, held identical byscripts/check-destination-routing-reason-mirror.mjsunderpnpm check:invariants. Every branch is latent on every install today: no production caller populatesOrderSyncRequest.destinationConnectionIds, so both columns areNULLeverywhere and the change is behaviour-preserving until a router is wired. - A source’s
listOrderFeedcursor is opaque, and a shop-side webservice restriction can force an adapter to narrow its own reconciliation guarantee (#2877): PrestaShop’s order feed normally reads a(date_upd, id)keyset cursor (#2605), but some shops refusedate_updas a sort/filter field on theorderswebservice resource outright (code 38, “Unable to filter by this field.”, naming a shop-specific allowed-field list that omits it).PrestashopOrderSourceAdapterdetects that refusal, remembers it per connection, and falls back to an id-ordered read for that connection from then on — new orders are still reliably surfaced, but an update to an order whose id has already been read is no longer re-observed by a poll on that shop, since nothing asks it to re-sort bydate_updany more. This is acceptable precisely because poll is the reconciliation backstop (#904) — the webhook path remains the primary, low-latency route an update reaches OpenLinker through, and is unaffected. See the adapter’s own header comment for the full mechanics; the cursor’s wire format is unchanged either way, by design (#2605). - How the buyer pays is a neutral fact beside the payment status, and it is never guessed (#3294):
PaymentMethodis the closedonline | bank_transfer | cash_on_delivery | unknownvocabulary carried onIncomingOrder/Order/OrderCreateand persisted onorder_records.paymentMethod(nullable, ready-path write only likeshippingAddressHash, no backfill).PaymentStatusanswers has it been paid; the method answers how, and the two are kept apart because a cash-on-delivery order is legitimatelycodand laterpaid. Five properties are load-bearing. (1) Only a platform’s own value is evidence - Allegro mapsONLINE/CASH_ON_DELIVERY/BANK_TRANSFER, PrestaShop the two core modulesps_cashondelivery/ps_wirepayment, WooCommerce the core gatewayscod/bacs, Shoperis_cash_on_delivery, Erlidelivery.cod; every other value, a plugin gateway or a missing field isunknown, which pre-fills no COD amount and selects no invoice payment form. (2) The COD amount is the order’s gross total (toCodToCollect, shared by every source so none spells “how much is collected” differently), stated only where the source identified cash on delivery. (3) A WooCommerce core COD order reportscod, notpaid: it is createdprocessing, and reading that as paid stripped the amount from the courier label; a payment date stamp from WooCommerce is still the strongest evidence and wins. (4) A contradiction is logged, never refused:checkPaymentFactsConsistencyflags a COD status beside a non-COD method, andpersistOrderwarns - the order is the fact. (5) The method is written back, and the operator’s mapping always wins: PrestaShop labels the order from it, WooCommerce records thecod/bacsgateway, Shoper takespayment_idfrom the payments mapping, and an invoice carries it asIssueInvoiceCommand.paymentMethod(inFaktcash/transfer, KSeFFormaPlatnosci1 / 6; transfer on inFakt only when a bank account is configured, and a KSeF cash invoice never prints the connection’s bank account). An inFakt CORRECTION does not carry the method - it keeps the connection default - because a correction has no order context; that is a named gap, not a guess. Unknown or absent keeps each destination’s behaviour byte-identical to before. The web surfaces name it in the order header chip and the fulfilment task’s Payment card through one copy table (payment-method-copy.ts; the wording says “Online payment”, never “Paid online”, because the method says how the buyer pays and not whether they have);unknownrenders nothing rather than a chip saying so. - Order analytics read model (#1985, ADR-039): makes order data queryable for
/analytics(#1976) without JSONB expansion.OrderRecordService.persistOrderdenormalizes four scalars ontoorder_recordsat write time —placedAt,currency,taxTreatment(nullmeans “not asserted by the source”, never defaulted),totalAmount— mirroring the existingdispatchByAt/fulfillmentStateprecedent (#927/#1108). It also writes a new child table,order_line_items(one row per order line, denormalizingsourceConnectionId+placedAtso a channel/time-bucketed per-product query never joins back toorder_records), viaOrderRecordRepositoryPort.upsertWithLineItems— both writes commit in one transaction, delete-then-reinsert per order so re-ingestion never leaves stale rows. Populated only forrecordStatus === 'ready'records, since anawaiting_mapping/source_deletedsnapshot’s items reference external, not internal, ids. No materialized view — ADR-039 rejects one at this persona’s volume (10–100 orders/day), matching the reasoning already established in ADR-036. #1985 itself ships no HTTP endpoint — #1987 (sales/channel aggregates, below) and #1988 (top products) build reads on top of this substrate. Cancellation exclusion is deferred to #1984’scancelledAtcolumn plus those downstream consumers joining back to it. - Sales & channel aggregates (#1987):
GET /analytics/sales(apps/api/src/analytics/http/sales-analytics.controller.ts) is the first HTTP consumer of the #1985 substrate — headline (revenue, order count, AOV,PERCENTILE_CONTmedian, units, cancelled count/value, a trend spanning the full selected range — daily up to 7 days, resampled into up to 7 buckets beyond that, #2899) plus a per-source-connection breakdown (same figures except median, which stays headline-only, each with arevenueShareand acoverageCompletesignal reusing #2083’sgetEarliestOrderDateByConnection), via the newIOrderRecordService.getSalesAndChannelAnalytics. Entirely intra-orders— no new cross-context edge. Currency (#2049/ADR-040 follow-up):revenue/averageOrderValue/medianOrderValue(headline and per channel) sumreportingTotalAmountrestricted to stamped orders (reportingCurrency IS NOT NULL) — one comparable currency, never a naive cross-currency sum;currencyreports which one (nullwhen nothing in range is stamped yet). The complementary unstamped slice is surfaced viaunconvertedCount/unconvertedValue(nativetotalAmount, informational, may mix currencies — never a KPI) rather than silently mixed in or dropped, andunconvertedCurrencylabels that evidence with the one native currency shared by every unconverted order (order_records.currency, a pre-existing #1985 column untouched by the FX epic —nullwhen the unconverted set itself mixes currencies). Gross/net tax-treatment normalization remains a separate, not-yet-scoped effort. - Net Sales, VAT-exclusive (
revenue.net*fields, net-sales tax-rate epic, ADR-063 § Consequences): alongside grossrevenue,getSalesAndChannelAnalytics/getDailyOrderAggregatescompute a VAT-exclusivenetRevenueper bucket, plusnetExcludedCount/netExcludedValuefor what a stored-rate requirement leaves out. An order is net-eligible only when it is nottaxRateEra = 'pre-rollout'history, carries at least one line, and — for gross-priced (taxTreatmentabsent/inclusive) lines — every line’sorder_line_items.taxRateresolves to a known fraction (resolveNetSalesTaxRate/ its SQL twinnetSalesRateFractionSql,libs/core/src/orders/domain/types/net-sales-tax-rate.types.ts); net-priced (exclusive) lines need no rate, since the storedunitPriceis already VAT-exclusive. Net isgross / (1 + rate), nevergross × (1 - rate)— the latter understates net bygross × rate²/(1+rate)and was a shipped bug caught only by an int-spec computed against a real fixture, not by the unit specs that had been (wrongly) asserting the buggy figure. - Operator opt-in for backfilled pre-rollout orders (#2469, epic #2452 Phase 5, amending ADR-063 § Consequences — “no such figure reads the marker yet” is superseded by this): ADR-063’s blanket pre-rollout exclusion is deliberately conservative — a
taxRateEra = 'pre-rollout'order never enters Net Sales even whenTaxRateBackfillServicehas since written it a real rate.analytics_display_settings.include_backfilled_tax_rates_in_net_sales(read byapps/api, threaded in as a plainincludeBackfilledPreRolloutboolean — see theanalytics --> orderscross-context-edge note above for why it is a parameter rather than anorders --> analyticsimport) makes the era clause alone vacuous (netSalesEraEligibleSql); it does not relax the rate-resolution requirement, so a pre-rollout order is admitted only when a real rate has actually been written to its lines. Turning the setting ON does not retroactively pull in an order whose rate is merely resolvable from the live catalogue but never written — net-sales arithmetic needs a stored rate to multiply by, and there is no SQL equivalent of a live catalogue read; that is whatPOST /analytics/coverage/tax/rerun-backfill(coverage category C) exists to close, by actually running the backfill. Noorder_recordsrow is mutated by flipping the setting — it is a pure query-time gate, instantly reversible in both directions, which is why the flag is a required parameter (never a repository-held default) on bothnetSalesOrderNetEligibleSqland its per-line siblingnetSalesLineNetEligibleConditionSql— a caller that forgot to thread it would silently keep reporting the pre-#2469 figure while the UI claimed the setting was on. The same flag is threaded intoTaxCoverageDetectionService’s category counts, or an order the setting just admitted into Net Sales would keep being reported as outstanding category-A work. - How the buyer pays is a closed, source-mapped fact beside the status (#3294):
PaymentMethod(online | bank_transfer | cash_on_delivery | unknown) rides onIncomingOrder/Order/OrderCreateand is denormalized ontoorder_records.paymentMethod(nullable, no backfill, no CHECK; written on the'ready'path only, read throughOrderRecord.paymentMethodOrUnknown). It is orthogonal toPaymentStatus- one answers “how is it paid”, the other “may it ship” - and each source adapter maps only values it has evidence for (AllegroONLINE/CASH_ON_DELIVERY/WIRE_TRANSFER, Erlidelivery.cod, PrestaShopps_cashondelivery/ps_wirepayment, WooCommercecod/bacs, Shoperis_cash_on_delivery); everything else isunknown, which never pre-fills a COD amount or selects an invoice payment form. Where a source can identify COD it statescodToCollectthrough the sharedtoCodToCollect(the gross total), andcheckPaymentFactsConsistencyflags contradictory method/status/amount combinations - logged bypersistOrder, never refused. WooCommerce behaviour change: a corecodorder readscodinstead ofpaiduntil WooCommerce stamps a payment date or the order iscompleted(cash collected), so anauto-on-paidinvoicing connection does not issue for an open COD order. Write side (destination method) and the invoice payment form are follow-ups.
5. Customers
Section titled “5. Customers”- Responsibility: Customer identity resolution, customer projections, multi-origin identity management
- Key Entities: CustomerProjection, CustomerAddressProjection, DestinationAddressMapping
- Location:
libs/core/src/customers/ - Key Features:
- Customer identity resolution with email fallback mode
- Multi-origin customer identity (same email across platforms → same internal customer)
- Customer projections (Model C) for debugging and retry support
- Configurable PII storage (hash-only mode for privacy compliance)
- Address reuse tracking via destination address mappings
- Identity Modes:
external_only: Only use external buyer ID mapping (no email fallback)email_fallback: Use email hash fallback if external mapping not found (may merge customers with shared emails)
- Provisioning Model: Destination-owned (Model A) - customers created in destination platform (e.g., PrestaShop)
- Projection Model: Lightweight internal storage (Model C) - non-authoritative projections for debugging
6. Listings (Offers)
Section titled “6. Listings (Offers)”- Responsibility: Marketplace offer/listing management, offer lifecycle, offer-to-product mapping
- Key Entities: Offer, Listing, OfferMapping, OfferStatus, OfferCreationRecord
- Location:
libs/core/src/listings/ - Capability: Uses
OfferManagerPortabstraction for offer operations (listing, quantity + field updates, offer creation, category directory, seller-policy discovery) - Key Features:
- Creating and updating offers on marketplaces
- Managing offer quantities based on inventory
- Offer-to-product mapping
- Offer status synchronization
- Price management for marketplace offers
- Offer mappings are populated via the
marketplace.offers.syncjob (pre-sync pipeline). - Allegro offer sync uses
GET /sale/offer-eventswith persisted cursor keyallegro.offers.lastEventId. - Non-blocking quantity acknowledgement (#2621):
AllegroOfferManagerAdapter.updateOfferQuantity/updateOfferQuantitiesBatchno longer block on Allegro’s terminal quantity-change status — they return as soon as Allegro accepts the write (QUEUED/ACCEPTED), and only a synchronousREJECTED(a definitive answer known at submit time) still throws. The “confirm later” half is the optionalPendingQuantityAckReconcilersub-capability (advertised-without-dispatch, mirroringOfferCreator/CategoryBrowser):reconcilePendingQuantityAcks(limit)reads the connection’s outstandingallegro_quantity_commandsrows and re-checks each distinct AllegrocommandIdonce — a batched write (#2622) persists several rows under one shared commandId, and Allegro’s status read answers for the whole command, so resolving every row from that one response (rather than one GET per row) avoids re-fetching the identical response once per offer. Remaining fan-out across distinct commandIds is bounded by the same operator-configurableresolveBatchConcurrencyceiling the EAN-resolve batch path uses (ADR-047/#2229), never an unboundedPromise.all. Reconciliation never throws for an individual command’s outcome — a genuinely failed command is persisted'failed'onallegro_quantity_commandsand warn-logged, since there is no caller left waiting to catch it; only a whole-pass infrastructure error propagates.IOfferQuantityAckReconcileService(core) + themarketplace.offerQuantity.reconcileworker job + scheduler task (allegro-quantity-ack-reconcile, default ON,*/2 * * * *, page limit envOL_ALLEGRO_QUANTITY_ACK_RECONCILE_PAGE_LIMIT) drive the reconcile pass per connection; the job reportsoutcome: 'ok'regardless of individual command outcomes, matching every other sweep-shaped job in the repo. - Offer status sync (#816): the
marketplace.offer.statusSyncjob refreshes the live marketplace publication status (active | activating | inactivating | inactive | ended) of mapped offers into theoffer_status_snapshotstable — the steady-state counterpart toOfferCreationRecord’s one-shot creation lifecycle. It reuses theOfferStatusReadercapability, enumerates OL’s own offer mappings paged by a rolling scan-offset cursor (allegro.offerStatus.scanOffset; Allegro has no bulk status endpoint), and writes a disjoint table from the creation poller (#447) so the two never race. See ADR-009. - Channel-side commercial snapshots (#2024): the same
marketplace.offer.statusSyncpass (and the samerefreshOnepath, #1760) also persists each mapped offer’s live marketplace price + available quantity into anoffer_commercial_snapshotstable - the third disjoint listings snapshot table alongsideoffer_status_snapshots(#816) andshop_product_status_snapshots(#1845). No second marketplace call:OfferStatusReadResultcarries an optional neutralcommercialobservation that Allegro and Erli each project off the offer/product resource theirgetOfferStatusalready fetched. CallingOfferReader.getOfferalongside would double the per-offer request count, and calling it instead would either push per-platform status mapping into core (MarketplaceOffer.statusis a raw-string passthrough, unlike the closed neutral union the status snapshot persists) or drop Erli’s frozen-stock cache side effect - see ADR-009 § Amendment (#2024). Every observed column is independently nullable andnullnever means zero: a sparse response records “not reported” rather than a0an operator cannot tell apart from a sell-out, and a good quantity is never discarded because the price was missing. The stored values are what the marketplace reports - already net ofstockSafetyBuffer(#1844) and already the output ofpricingRule(#1843) - so they are operator-facing as “on channel”, never as OL’s own stock/price. The commercial write is strictly supplementary and wrapped in a catch: a failure warn-logs and continues, so it can never abort the #816 status pass or stall its scan cursor. Erli’s price/quantity were blank on a default deployment until #2230: the same commercial write hangs off Erli’smarketplace.offer.statusSynctask, which was strict opt-in (OL_ERLI_OFFER_STATUS_SYNC_SCHEDULER_ENABLED === 'true') while Allegro’s defaulted ON - so an operator saw no error and no log line, just an empty column, and because the unregistered task also wrote no status snapshot, every mapped Erli offer sat permanently in theUnsyncedlifecycle bucket and was invisible on the default/listingstab. The gate existed for a status-specific reason (#1063/#992: a wrong or absent Erlistatusfield would fall through toinactiveand writeinactivesnapshots for every mapped offer), it never applied to the commercial payload, and #2230 retired it - the sandbox swagger declaresProductResponse.statusasenum ["active","inactive"], so the premise had expired. Both Erli tasks now register unconditionally and keep their per-tickenabledEnvVar, soOL_ERLI_OFFER_STATUS_SYNC_SCHEDULER_ENABLED=falserestores the old behaviour. Read surfaces + FE rendering are follow-ups in the same epic. - Offer status read + post-terminal reconcile (#1760): the snapshot is the authoritative operator-facing live status.
OfferStatusReadServiceexposes it per product (GET /listings/products/:productId/offer-status, rendered on the products drawer), and a manualPOST .../offers/:externalOfferId/refresh-statusforce-reads one offer. Because the creation poller (#447) terminalises a record todraft/failed(POLL_TIMEOUT)when Allegro’s validator outruns its ~9-min budget — and Allegro may activate the offer minutes later — the poller then schedules a bounded, delayedmarketplace.offer.refreshSnapshotjob (~2/8/20 min) that re-reads live status viaOfferStatusSyncService.refreshOneand upserts the snapshot, self-rescheduling while in-flight. The terminal creation record is never mutated (ADR-009 disjoint-tables invariant); the snapshot is the moving part, with the hourly sync as the backstop. Spun out of #1520. - Create-path status snapshot (#2039): before this, a successful offer create wrote no
offer_status_snapshotsrow at all —upserthad exactly two callers, both inOfferStatusSyncService— so a freshly published offer had no live status until the hourly rolling scan happened to reach it (~40 h worst case on a 4,000-offer catalog, since a new mapping enters thecreatedAt DESCscan at an offset the cursor has already passed). The fix is one rule: when an authoritative publication status is already in hand, persist it; otherwise write nothing. Adapters report it through an optionalCreateOfferResult.publicationStatus(set only when the platform’s create response carried a status — Allegro mapspublication.status; an adapter with nothing authoritative, e.g. Erli’s async 202, omits it and no row is written rather than guessing a status the operator would act on).IOfferStatusSyncService.recordObservedStatusis the single write seam — it owns the snapshot port and every otherupsertcaller — invoked from exactly two places: unconditionally at the end ofOfferCreationExecutionService.executeCreation(which also covers the previously-uncovered case wherescheduleFirstPollfails and no poll ever runs), and on the creation poller’sactiveterminal, where the live read just happened (scheduling a delayed re-read of what was just read was waste;POLL_TIMEOUT/draftkeep their #1760 ladder). Both call sites are best-effort and never throw:loadOrCreateRecordcarries no terminal-state guard, so a throw after the create would let a job retry callcreateOfferagain — Allegro does not dedup product-offer creates, so that would trade a missing status row for a duplicate live offer. Making the table multi-writer also required theupsertto become a freshness-guardedINSERT … ON CONFLICT DO UPDATE— see the ADR-009 amendment above. Erli status coverage stays with #992 (its status read defaults unknown values toinactive, which the review-#1063 scheduler gate deliberately suppresses). Two adjacent operator-facing gaps close in the same change. (1) Reconcile-key scoping: the #1760 ladder keyed its jobrefreshSnapshot:{externalOfferId}:{attempt}against a globally unique, TTL-lesssync_jobs.idempotencyKey, so one offer id could only ever receive ONE attempt-1 reconcile - across connections (an ErliexternalOfferIdIS the internal variant id, shared by every Erli connection), across re-creates, and across retry waves, each silently deduped against a long-dead job. AreconcileIdnow identifies one chain: minted when attempt 1 is scheduled, carried through the handler’s self-rescheduled follow-ups, and folded into the key by the sharedbuildOfferRefreshSnapshotIdempotencyKey(one format, two writers). The field is optional so a chain already in flight across the deploy keeps rescheduling under its legacy key instead of dead-lettering; at-most-one chain per terminalisation was never the key’s job anyway - the record state machine owns it (pollOnceno-ops once the record leavesvalidating). (2) The per-product status read now reports mapped-but-unread offers:IOfferStatusReadServicereturns anOfferPublicationStatusViewper offer OL has mapped to the product, withpublicationStatus: null/lastStatusSyncedAt: nullwhen no snapshot exists, instead of snapshot rows only. Previously “this product has no offers” and “its offers have no status yet” were the same empty response, and because the FE renders the manualPOST .../refresh-statusaction per returned row, the mitigation was unreachable for exactly the offers needing it (the panel short-circuited to an empty state) - curl was the only route. The panel now lists such an offer asNot synced yetwith aCheck statusaction. - Why a listing cannot sell, and who it is about (#2231): a marketplace that reports a REASON alongside a status had nowhere to put it.
OfferStatusReadResult.validationErrorswas, for Erli, only ever populated from arejectedstatus branch that could not be reached - Erli declaresstatusasenum ["active","inactive"], nullableand has no rejection status, andstatusReasonis not a property of itsProductResponseat all - sovalidationErrorswas permanently[],resolveOfferLifecyclerouted every blocked Erli offer toDraft, and theInvalidbucket could never contain one. The reasons were inProductResponse.buyableProblems(an 18-value enum) andarchived, neither of which OL read. Three pieces close it. (1) The adapter maps each code to an operator-facing sentence, keyed by the platform’s owncode- the copy lives adapter-side because only the adapter knows what its codes mean, and the raw value survives the round trip so an operator can quote it in a support ticket and an unrecognised code is surfaced rather than dropped.archived: truereports'ended', not'inactive': Erli’s own description is that an archived product cannot be bought and disappears from the seller panel, and'ended'is additionally the only bucketcountByConnectionAndVariantstreats as re-listable, so the offer becomes fixable through the wizard instead of being silently skipped. Astatus: 'active'offer stays'active'even carrying problems - Erli is the authority on whether it published the offer, and overruling it would put words in the marketplace’s mouth. (2) Core gains the neutral vocabulary (OfferValidationProblem+OfferValidationScope, withreadValidationProblems/splitOfferValidationProblemsas pure helpers besideoffer-lifecycle.types.ts) and persists it additively in thestatusDetailsjsonb:validationMessagesis still written first and is still what the lifecycle rule and its SQL twin read, so no row moves between buckets for a reason other than now having messages at all, and no migration is needed.resolveOfferLifecycleis unchanged - populatingvalidationErrorsis what moves the row intoInvalid, through the rule that already existed. (3) The offer-vs-account split is declared by the adapter, never inferred in core. Three of Erli’s reasons describe the SHOP (shop-activity,shopKyc,blocked); the platform reports those against every one of the seller’s offers, so per-row rendering would stamp one sentence on every row and bury the single actionable fact. The adapter marks themscope: 'account'and core splits on that neutral field - a core-side list of Erli strings would have been marketplace vocabulary inlibs/core, and an adapter that declares nothing normalises to'offer'and behaves exactly as before. The frontend renders the three surfaces the split implies: one offer-scoped reason per row plus an overflow count (.listing-cell__reasonis single-line by design, and the full list rides in itstitle), a muted variant so the red line always means outstanding work (an offer switched off with nothing reported says so quietly, and a row whose only problem is shop-level points at the notice instead of repeating it), every reason with its raw code in the per-productOfferPublicationStatusPanel, and one connection-levelAlertabove the table per affected connection - derived from the rows the page already fetched, which is also why it counts “the listings shown” rather than claiming a total the paged, filtered read cannot support. Erli’s status-sync scheduler task is separately opt-in (#2230), so on a default deployment there is no snapshot to render until that lands. - Stale-variant offer pause (#1689): closes the fail-open marketplace side of a master-side deletion (#1599). When
product_variants.isStaleflips true, theevents.master.deletionstream (see § Data Flow) is consumed by a worker stream handler (MasterDeletionToJobHandler) that enqueuesmarketplace.offer.pauseStale; the coreStaleOfferPauseServicere-verifies each variant is still stale immediately before zeroing its mapped offers’ quantity (never trust the event’s variant list as authority — a reappearance racing the event must never zero a live offer), fanning out across every connection the variant is mapped on. Quantity 0 is the pause primitive — no adapter in the repo exposes a deactivation/publication write, and zero already stops the offer selling on every shipped marketplace; a first-classOfferDeactivatorcapability is a deferred follow-up. Because the deletion event is published at-most-once (fire-after-commit, never re-emitted once the rows are already stale), an hourlymarketplace.offer.pauseStaleSweepreconcile task (default ON, perOfferManager-capable connection) re-asserts the pause by reading straight from the persistedisStaleflag viaOfferMappingRepositoryPort.findStaleMappedVariants— the guarantee that survives a lost event. A concurrentinventory.propagateToMarketplacesrun skips its offer branch for a stale variant, closing the re-raise race between the two flows. The bulk offer wizard (BulkListingSubmitService.expandVariantJobs) skipsisStalesiblings/selections so a deleted variant can never become a new offer either.- Operator-facing honesty (#1689): an order whose item resolution fails because the mapped variant is
isStalereadsOrderRecord.recordStatus = 'source_deleted'(distinct from the ordinary, self-healing'awaiting_mapping'gap) withmappingFailureReasoncarrying the reason — surfaced as a dedicated health bucket + list badge + detail-timeline narration, and excluded from bulk dispatch with its own ineligibility reason. Amaster.product.syncByExternalIdjob whosebusiness_failurewas caused by the deletion carriesoutcomeReason: 'master_deleted'onsync_jobs, rendered as a distinct jobs-list label instead of the generic “business failure”. - Known gap — Erli seller-frozen stock:
ErliOfferManagerAdaptersilently skips the quantity write when the connection has seller-frozen stock (isStockFrozenCached), so the pause is a no-op on such a connection — a deliberate existing adapter behaviour (a seller who froze stock owns it), not something #1689 changes. No operator-facing warning surfaces this gap yet; a follow-up. - Remediation note — delete-then-recreate: recreating a deleted product at the master usually mints a new master-native id, so the old
identifier_mappings/product_variantsrows tied to the deleted id stayisStaleforever (nothing re-associates them with the new id). The paused offer stays paused; an operator noticing a “phantom” stale variant should re-map or delete the orphaned mapping rather than expect it to self-heal.
- Operator-facing honesty (#1689): an order whose item resolution fails because the mapped variant is
- Offer linking by barcode uses master-catalog scoping and links only on unique matches.
- Destination-aware duplicate guard (#1837, semantics corrected by #1933): publishing behaves asymmetrically per destination kind. The marketplace path is guarded at intake:
BulkListingSubmitService.filterAlreadyListedis the authoritative backend guard - it excludes from the batch every variant that already carries an active offer mapping on the target connection, because re-listing would both create a duplicate offer (batch-scoped idempotency keys don’t dedup across batches, and Allegro doesn’t enforce uniqueness on product-offerexternal.id) and fragment the auto-grouped listing (#824). Only past that guard does the execution layer (OfferCreationExecutionService) callcreateOfferunconditionally, so on the wizard path - the single FE entry point since #1754 - a duplicate offer is never produced. The shop path does not filter:ProductPublishExecutionServiceresolves theShopProductmapping first and upserts the existing product (PUT /products/{id}), which is a legitimate re-publish.PublishedVariantsService.getPublishedVariantIds(connectionId, variantIds)(POST /listings/published-variants) unions two connection-scoped reads -OfferMappingRepositoryPort(marketplace) +ShopProductMappingRepositoryPort(shop) - to report which variants already have a listing there; because a connection carries only one mapping kind the union is destination-kind-agnostic and can’t double-count. The FE (picker + wizard Review) renders an “already on {destination}” chip and a confirm before publishing, resolving the wording from the connection’s capabilities, neverplatformType. The confirm’s meaning differs per kind, matching the backend: marketplace says the already-listed variants “will be skipped, not duplicated” and its primary action is “Publish remaining variants” (it is a warning about a partial submit, not an offer to duplicate); shop says “updates the existing product (upsert)” / “Update existing” and really is a soft warning - the publish proceeds for every selected variant. Making marketplace offer-create mapping-idempotent (so an explicit operator confirm could legitimately re-publish rather than skip) is out of scope. The two post-filter outcomes on the marketplace path are reported explicitly (#1933): if every expanded variant was filtered the submit fails withAllVariantsAlreadyListedException-> 400 (distinct from the genericEmptyBulkSubmissionException, which stays reserved for a submission that resolved to no variants at all); if only some were, the batch proceeds andsubmitreturnsskippedAlreadyListedCount, threaded through the response DTO to the FE success toast so the operator sees the batch shrink. - Category parameter sections (#415 / #419): per-platform create-offer requests may split category parameters into offer-section and product-section payloads (
body.parameters[]vsbody.productSet[].product.parameters[]on Allegro — the latter carries Brand, Model, Manufacturer-code, etc., and mirrors the shape Allegro returns fromGET /sale/product-offers/{offerId}). The neutralCategoryParameter.section: 'offer' | 'product'field carries this distinction through to adapters; the wizard renders both kinds in one unified list and the FE serializer (serializeAllegroParameters) splits them into two arrays at submit time. Adapters that cannot distinguish always emit'offer'. - Public surface:
@openlinker/core/listingsexposes pure contracts (ports, types, capability guards, entities, exceptions, service interfaces, Symbol tokens) safe to value-import from any sibling package. Runtime wiring (ListingsModule+ the 8@Injectableservice classes) lives on the@openlinker/core/listings/servicessubpath — kept separate to prevent runtime circular requires when sibling packages value-import from the main barrel (#337/#359). Cross-context ORM-entity access (host-only) is routed through@openlinker/core/<ctx>/orm-entitiessub-barrels (#594) — seedocs/engineering-standards.md § Import Aliasesfor the general rule. - Bulk-flow lifecycle (#726): a
BulkOfferCreationBatch(#734) is the parent aggregate; four core application services own its phases —BulkOfferCreationSubmitService(intake, #736),OfferCreationEnqueueService/OfferCreationExecutionService(per-child enqueue + run, shared with single-offer),BulkOfferCreationProgressService(counter advancement gated at-most-once bybulk_batch_advancements, #737), andBulkOfferCreationRetryService(#742). All four delegate to the same single-offer primitives — there’s no parallel “bulk” pipeline.BulkOfferCreationRetryServicereopens a terminal-state batch: per failed record it deletes thebulk_batch_advancementsrow, decrementsfailedCountlock-stepped to the record reset, then transitionscompleted | partially-failed | failed → runningonce after the loop. The per-record V2 payload is rebuilt from the persistedrequestsnapshot + the parent batch’ssharedConfig.generateDescription/sharedConfig.descriptionTone(the snapshot itself doesn’t carry AI flags — they’re batch-scoped). Each retry wave uses a wave-distinct idempotency key (bulk:{batchId}:variant:{variantId}:retry:{retryWaveId}) to bypass the 7-day TTL on the original submit’s dedup gate. - Multi-variant expansion (#824):
BulkOfferCreationSubmitService.submittreats each submitted id as a primary-variant id and, for a multi-variant product, expands it into one offer-creation job per sibling variant before persisting the batch (sototalCountmatches the real fan-out). Each variant-offer sources itsstock.availablefrom per-variant master inventory (IInventoryQueryService.getAvailabilityByVariantIds, #823) — master is authoritative, including 0, so an out-of-stock variant lists as 0 rather than being backfilled with the operator’s bulk quantity (the operator quantity stays the source for single-variant / passthrough offers). Each offer self-links to its own Allegro catalog product by its own barcode (siblings drop the wizard-resolvedproductCardId). Allegro auto-groups the resulting offers into one buyer-facing listing from the Product Catalog (GTIN + distinguishing parameter) — the explicit variant-set API (/sale/offer-variants) was removed 14 Apr 2026 and is not used. Single-variant products and unknown ids pass through unchanged. Emitting OL variantattributesas explicit Allegro distinguishing parameters is a deferred follow-up (grouping already works off each variant’s own catalog product). - Neutral variant-grouping command field (#1065):
OfferBuilderService.buildCreateOfferCommandstamps a platform-neutralCreateOfferCommand.variantGroup(OfferVariantGroup= an opaquegroupIdshared by every sibling — today the parent OL product id — plus this variant’s distinguishingattributes, flattened fromProductVariant.attributes) for a sibling of a multi-variant product (getVariantsByProductId(...).length > 1); single-variant / simple products leave it absent and list standalone. The field is the marketplace-neutral seam for explicit grouping: explicit-grouping adapters map it to their wire shape (Erli →externalVariantGroup+attributes, #986;groupIdis BODY-ONLY and must never be path-routed), while auto-grouping adapters (Allegro) ignore it and group off their own catalog product (#824). No platform name leaks into core — the adapter owns the neutral→wire mapping. - Unified offer-creation FE entry (#1754): the frontend has a single offer-creation entry point — the bulk offer wizard.
/listings“Create offer” opens a multi-select, paginated, searchable product picker (whole product = all variants, a single variant, or a mix across products) that routes into the wizard atbulk-create/wizard?productIds=…[&variantIds=…]&connectionId=…; a single selected variant renders the wizard’s flat single-offer path. The old per-platform single-offer wizards (AllegroCreateOfferWizard,erli-create-offer-wizard) and theirofferCreationWizardplugin-contribution dispatch were removed — no backend/job change (the wizard already delegates to the same single-offer core primitives, #726). - Borrowed-taxonomy mapping reuse (#1045, ADR-023 §40/§83): a destination that borrows its taxonomy (Erli — accepts Allegro ids verbatim, ships no
CategoryBrowser/CategoryParametersReader) reuses an operator’s existing PrestaShop→Allegro category and attribute mappings with zero re-authoring. It declares the owner taxonomy it consumes via theTaxonomyBorrowercapability (getBorrowedTaxonomy(): TaxonomyOwner, e.g.'allegro');OfferBuilderServicethreads that value + the mastersourceConnectionIdintoCategoryResolutionServiceandAttributeProjectionService, which fall back from a destination-keyed lookup to the owner’s provenance-matching rows (destination_taxonomy_provenance). Capability-driven, neverplatformType; the downstreamsource:"allegro"emission was already in place (#985/#1096) — #1045 wired the resolution half. #2210 extends borrowing from the operator’s mappings to the owner’s live catalogue. A borrowing destination has no product catalogue of its own, soCategoryResolutionService.resolveEanMatcherresolves the EAN lookup through a peer connection that owns the borrowed taxonomy and declares EAN matching - so a resolve on an Erli connection now issues up to one Allegro/sale/productscall per variant on the peer’s OAuth credentials, against the peer’s rate-limit budget and cache keys. That cross-connection blast radius is the reason three properties are load-bearing rather than incidental. (1) The borrow is optional and never fatal: the discovery listing islazy(no adapter is constructed while candidates are filtered), the destination’s own connection is dropped first, and both the listing and the one surviving construction are caught - a third party’s misconfiguration degrades tono-match, never to a 500 on the batch route or afailedstream, because that would make an optional upgrade worse than not having it. Owner selection is oldest-connection-wins, the same rulefindBySourceCategoryuses for borrowed mappings, so the catalogue lookup and the mapping fallback can never mean different owners. (2)getBorrowedTaxonomy()must be ENVIRONMENT-QUALIFIED and must agree with the catalogue the borrower actually reads. An Allegro connection declares'allegro'or'allegro:sandbox'per environment (TaxonomyIdentityProvider, #2063) and the owner match is exact, so a borrower that names the wrong environment silently matches nobody and every variant degrades - and becauseresolveTaxonomyOwneruses the borrower’s value verbatim, the projection scope it reads and can be elected under would name an environment it does not read from (the #2063 failure ADR-037’s identity rework exists to prevent). Erli therefore resolves both answers through one shared helper (erli-allegro-taxonomy.policy.ts) rather than deriving them independently: the Erli Shop APIenvironmentand the Allegro catalogueallegroEnvironmentare different axes that only type-check together because both are'sandbox' | 'production', and inferring one from the other declared a sandbox owner for a production catalogue on the ordinary “Erli sandbox, real Allegro catalogue” install. (3) The operator’s ADR-031 category-access opt-out covers it. Borrowing is a different mechanism from the destination’s own category browsing - different credentials, different budget - so it slipped theallegroCategoryAccessEnabled: falsetoggle (#1934/F10) while producing exactly the effect the operator switched off.TaxonomyBorrower.allowsBorrowedCatalogueLookup?()is the neutral seam for saying so: optional (absent means yes), andfalsedegrades the resolve exactly as “no owner connection exists” does, leaving #1045 mapping reuse - which makes no network call - untouched. - Operator attribute mapping rules (#1841): an operator-authored, deterministic (no AI) rule layer consumed by
AttributeProjectionService, on top of the base attribute-mapping model (#1038). Three rule kinds — fixed (set a destination attribute to a constant), copy-remap (copy a source attribute with per-value remap, e.g.36S -> 36), place-value (fill from product metadata: name / variant / manufacturer / ean / sku / weight). Rules live in themappingscontext (AttributeMappingRuleentity +attribute_mapping_rulestable; kind-specific data in aconfigjsonb, scope + target in real columns) and are read throughIMappingConfigService.getAttributeMappingRules. They are scoped (all optional, AND-combined) by source connection, destination category, manufacturer (equality), and product-name phrase (substring), and ordered bypriority(lower first; a later rule wins for the same destination parameter name, and rules win over the base attribute mapping). Because integration is at the shared projection layer, the same rules serve BOTH marketplace parameters (owns/borrows paths) and WooCommerce/shop attributes (product-publish path) automatically.place-valuereads product-derivedAttributeProjectionInput.metadata, assembled by thebuildProjectionMetadatahelper at the offer-builder and product-publish-builder call sites (manufacturer derived from a well-known product feature). Operator-authored viaconnections/:connectionId/attribute-rules(GET/PUT/DELETE) and the connection Mappings page “Attribute Rules” tab (capability-gated toOfferManager/ProductPublisherdestinations). - Bulk shop-publish per-item transport (#1831, ADR-024): a bulk shop-publish batch is N independent publish decisions that happen to share a connection +
status.stock/priceare already per-item (#1414);contentwas batch-shared and category placement + parameters were server-derived by the #1042 builder. #1831 adds optional per-itemcontent/destinationCategoryIds/parameterstoBulkShopPublishSubmitItemInput(+ the request DTO) that override the batch-shared / server-derived defaults, threaded end-to-end (submit →ProductPublishEnqueueServiceV1/V2 payload →shop.product.publishhandler →ProductPublishExecutionService→ProductPublishBuilderService). Precedence in the builder: a defined per-itemdestinationCategoryIds(even empty ⇒ publish uncategorised) skipsCategoryProvisionerprovisioning; a defined per-itemparameters(even empty) skips attribute projection + the required-parameter gate; per-itemcontent(chosen at submit viaitem.content ?? batchContent) still merges over master-product fallbacks. Every override is optional — omitting a field keeps the pre-#1831 batch-shared / server-derived behavior (backward compatible). FE consumption is #1830. - Shop category browse (#1834, ADR-024): shops gain a read counterpart to
CategoryProvisioner(the write path).ShopCategoryBrowseris a sub-capability ofShopProductManagerPort(browseCategories(parentId?)→ neutralShopCategory[], co-locatedisShopCategoryBrowserguard) — the shop-side sibling of the marketplaceCategoryBrowser(onOfferManagerPort). Unlike a marketplace’s closed leaf-gated taxonomy, a shop lets a product be placed in any node, soShopCategorycarries noleafflag. It is advertised-without-dispatch (declared in the WooCommerce manifestsupportedCapabilitiesfor host/FE discovery, resolved by narrowing the dispatchedProductPublisheradapter with the guard — nevergetCapabilityAdapter('ShopCategoryBrowser'); not inCoreCapabilityValues). The WooCommerce adapter implements it overGET /products/categories(parent-scoped, paged at the WC maxper_page=100);ShopCategoryBrowseService+GET /listings/connections/:id/shop-publish/categoriesexpose it, and a self-contained FEShopCategoryPickerModal+useShopCategoriesQueryback the publish edit flow’s category picker (mounted by #1830). Since #2085 the capability is no longer on that read path:ShopCategoryBrowseServicedelegates to theDestinationCategoryprojection andbrowseCategoriesis reached only by thedestination.taxonomy.syncjob — see Destination taxonomy read model § Wave 2c below. - Shop-publish operational parity (#1845): brings the shop-publish path to parity with the mature offer path across four axes. (1) Partial-submit atomicity —
BulkShopPublishSubmitServicemirrorsBulkListingSubmitService: on a mid-fan-out enqueue failure it reconcilestotalCountdown to what actually reached the stream, deletes orphaned pre-createdListingCreationRecords, and level-triggers the terminal-status derivation (succeeded + failed === totalCount) so a strandedrunningbatch can never occur (previously it flipped the whole batchfailedwhile already-enqueued children kept running). (2) Status reconcile — the steady-stateshop.product.statusSyncjob drains one page of a connection’s published/draft records per tick viaShopStatusSyncService, reads live shop-side status through the newShopProductStatusReadersub-capability ofShopProductManagerPort, and upserts ashop_product_status_snapshotsrow (neutralShopPublicationStatus = published | draft | unpublished | removed) — the shop-side counterpart tooffer_status_snapshots(#816). WooCommerce maps its nativepublish/draft/pending/private/trash+ a 404 onto the neutral union; scheduled per WC connection (ProductPublisher-gated, hourly). (3) Retry —BulkShopPublishRetryServicere-runs only the failed children, rebuilding eachshop.product.publishV2 payload from a new per-itemListingCreationRecord.requestsnapshot (persisted at enqueue) under a wave-distinct idempotency key (mirrorsBulkListingRetryService#742). (4) Duplicate handling —ProductPublishExecutionServicenow swallows a first-publishDuplicateIdentifierMappingErroronly after verifying the winningShopProductmapping claims the same internal variant; a divergent mapping raisesShopProductMappingConflictExceptioninstead of silently mis-linking. - Shoper as a publish destination (#3712):
ShoperProductPublisherAdapterimplementsShopProductManagerPort(ProductPublisher, opt-in - not indefaultEnabledCapabilities, since it writes to the shop). Create isPOST /products(answers the bare id), update is a partialPUT /products/:id; visibility istranslations.<lang>.active(Shoper’s default is draft), the language key is the shop’s default language, andcategory_idis REQUIRED by Shoper, so a create with no category is refused until aCategoryProvisioner(#3713) lands. Multi-variant products are refused, not approximated: Shoper variants areproduct-stocksrows keyed by option ids that must already exist in the shop, and creating them was not verified. Images are sent after the product exists, asPOST /product-images {product_id, url}(Shoper fetches the image itself; the first becomes the main one): only to a product that has none (main_imagenull), so a re-publish never appends duplicates, and every image failure is awarning, never an error - the product mapping is persisted only afterpublishProductreturns, so a throw would retry into a second product. An internal-host image URL cannot be fetched by Shoper and comes back as a warning. Everything else a command carries that this adapter does not write (tags, parameters, sale price, dimensions, tax class) comes back aswarnings. The description grammar was measured live: Shoper keeps rich HTML (tables,div,img, links) and stripsscript,iframe srcandon*handlers. Findings and the live-check record:docs/plans/implementation-plan-shoper-product-publisher.md. - Shoper creates the categories a publish needs (#3713): the same adapter implements
CategoryProvisioner, so the category path of the master product is mirrored onto the shop and a create no longer needs an operator-typed category id. Three live findings shape it. Shoper REQUIRESparent_idonPOST /categories(0= a root), and it refuses neither a duplicate name under one parent nor an unknown parent id (that one creates an orphan the tree does not show) - so the lookup before every create is the whole of the idempotency, and only ids the adapter found or created are ever passed as a parent. Because a duplicate is accepted, two publishes that race to create the same node would otherwise diverge: after each create the directory is read again and the LOWEST id wins, so both settle on one node; the loser’s empty twin is logged and left, never deleted, since the other publisher may already have returned it. A category the tree does not place (no depth) is never matched, its parent being unknown. The tree + list read is oneShoperCategoryReadershared with ProductMaster rather than a second reader. Declared with the adapter and opt-in likeProductPublisher. Record:docs/plans/implementation-plan-shoper-category-provisioner.md. - Shop global-attribute read + publish linkage (#1835, ADR-024): shops previously emitted every neutral publish
parameteras a per-product free-text custom attribute ({name, options}), droppingvaluesIds— which cannot power storefront filtering.ShopAttributeReaderis a sub-capability ofShopProductManagerPort(listAttributes()+listAttributeTerms(attributeId)→ neutralShopAttribute[]/ShopAttributeTerm[], co-locatedisShopAttributeReaderguard) that reads the shop’s store-wide global attributes (pa_*) and their predefined terms. Advertised-without-dispatch (declared in the WooCommerce manifest for discovery, resolved by narrowing the dispatchedProductPublisheradapter — not inCoreCapabilityValues). The WooCommerce adapter readsGET /products/attributes+GET /products/attributes/{id}/terms, and on publish links a real global attribute when a neutralOfferParametercarriesvaluesIds(term ids) plus a numericid(the global-attribute id) — emitted as{ id, options: <term names>, visible }— keeping free-text{ name, options }as the fallback.ShopAttributeReadService+GET /listings/connections/:id/shop-publish/attributes[/:attributeId/terms]expose the reads, and a self-contained FEShopAttributePicker(+useShopAttributesQuery/useShopAttributeTermsQuery) backs the publish edit flow’s structured attribute picker (mounted by #1830). - Native WooCommerce variable products / variations (#1836, ADR-024): lifts the variable-product deferral from ADR-024 §Consequences. A multi-variant OL product now publishes as one shared WooCommerce
type:'variable'parent + oneproducts/{parentId}/variationsentry per sibling — a single-variant / simple product keeps the pre-#1836type:'simple'path unchanged.ProductPublishBuilderServicepopulates the shop-publish sibling of the marketplaceOfferVariantGroup(#1065) —PublishProductCommand.variantGroup: PublishProductVariantGroup(groupId= the OL product id, this variant’s own distinguishing attribute values, plusgroupAttributeValues— the UNION of every sibling’s values per attribute name, since the parent must declare its full variation-axis option set up front) — whenevergetVariantsByProductId(...).length > 1, mirroring the #1065 populate rule.ProductPublishExecutionServiceadditionally resolves the parent’sShopProductmapping (keyed ongroupId, distinct from the variant’s own mapping — no schema change, since product and variant internal ids are distinctly prefixed and coexist in the sameShopProductentityType) and stamps it ontovariantGroup.externalParentProductId;PublishProductResult.externalParentProductIdreports the resolved (created-or-reused) parent id back so the execution service persists that mapping the first time it resolves, under the same concurrency-safe swallow-or-conflict handling (#1845) already used for the variant’s own mapping. The WooCommerce adapter’s parent body carries variation-flagged custom attributes ({name, options: <every sibling's value>, variation: true}— custom, not globalpa_*, sinceProductVariant.attributesare freeform) and agroupId-keyed slug (stable regardless of which sibling triggers the parent upsert); each sibling’s own variation body carries its price/sku/stock/image/barcode/weight/commerce fields plus its own attribute value (WC’s singularoption, not the parent’s pluraloptions). - Destination taxonomy read model (#1979, Wave 1 of #1937, ADR-037): “browse a destination’s category tree” existed as four unrelated fragments, the only persisted one being the platform-named
allegro_category_cacheinapps/api(which the worker cannot reach).DestinationCategoryreplaces it with a neutral, core-owned projection inlistings—{ taxonomyOwner | connectionId, externalId, name, parentId, leaf?, syncedAt }— read through one service,IDestinationTaxonomyService(browse+search). The projection is keyed by taxonomy OWNER for a marketplace and by CONNECTION for a shop, and that split is a correctness requirement rather than a style choice: every seller shares one Allegro tree (so two connections must not duplicate it, and a borrowing destination like Erli reads the owner’s rows viaTaxonomyBorrower— #1045’s principle applied to the tree itself), while a WooCommerce store authors its own. Both columns are nullable, so uniqueness needs two partial unique indexes (the NULL-distinct patternproduct_content_fieldalready uses). Scope resolution probesOfferManagerthenProductPublisherand is capability-driven, never aplatformTypeswitch — an owner DECLARES its identity via theTaxonomyIdentityProvidersub-capability (getTaxonomyIdentity(): TaxonomyOwner), and an adapter declaring nothing resolves tonullso the sync skips it instead of writing rows under a guessed owner. Wave 1 inferred that value fromplatformTypeinstead; #2063 replaced the inference becauseplatformTypecannot express an axis the platform splits its tree along — every Allegro connection carries a requiredenvironmentresolving to a different API host, so sandbox and production collapsed onto one'allegro'scope, overwrote each other’s rows, and the watermark sweep deleted the loser’s whole tree on each completing run (hence the added'allegro:sandbox'value).TaxonomyIdentityProvideris deliberately distinct fromTaxonomyBorrower— the latter answers “whose mappings do I reuse?” forOfferBuilderService(#1045), and merging them would have silently rerouted every Allegro offer build onto the borrower branch of mapping resolution. Reads never touch the live platform; theCategoryBrowser/ShopCategoryBrowsercapability is used only by thedestination.taxonomy.syncjob, which is paged and resumable and records disappearance via asyncedAtwatermark sweep rather than deletion, so a mapping to a removed category fails loudly instead of resolving to a stale node. Resumability is DB-derived (#2061):expandedAtrecords which run expanded a node, so the frontier is a query rather than an id list on the cursor (which stays scalar, like every other cursor in the repo), the run is owner-portable across a source re-election, and termination is inherent — a node reachable from two parents or through a cycle cannot re-enter the frontier, provided theON CONFLICT DO UPDATEnever assignsexpandedAt. Runs are serialized per scope by aSyncLockPortlock, which is what makes ADR-037’s “at most one in-flight run per owner” true rather than aspirational. The sweep additionally requires that the run observed at least one row: an empty frontier also describes a run that saw nothing (a hiccuping platform returning no roots), and sweeping on that reading would delete the whole scope. This follows the OL-store-backed principle ADR-033 § Phase 1 amendments established — walking a tree is N platform calls, and a taxonomy you must paginate one parent at a time cannot be searched at all.searchis therefore the point of the model and is a bug fix, not a new feature: the existing pickers filter only the currently-loaded level, so an operator searching from the root gets nothing and concludes the category does not exist. It matches a normalizedsearchTextcolumn (diacritic-folded in application code, soodziezfindsOdzieżandartykulyfindsArtykuły) withLIKE, accelerated by a GINgin_trgm_opsindex — deliberately not the%similarity operator, which errors wherepg_trgmis absent, so correctness never depends on an extension. Breadcrumbs are derived per query by a recursive CTE over the matched rows, not materialized (a resumable paged sync inserts children before their ancestors exist). That CTE carries a depth cap, because the projection cannot be assumed acyclic: the upsert reassignsparentIdon conflict and re-parenting is a normal case (a node reachable from two parents is re-upserted when the second expands), so two individually-valid observations across a paged sync — A under B, then B under A after a platform reorganization — persist a cycle. #2061’sexpandedAtguard terminates the sync on that shape; it says nothing about reading the rows it wrote, and with nostatement_timeoutconfigured an uncapped walk would pin a pooled connection indefinitely on an operator page load. Wave 2a (#2074) repointed the marketplace reads.mapping-options.controller’ssource/categoriesandsource/categories/:id/pathnow read the projection, and a neutralGET /listings/connections/:id/taxonomy/categories[/search]exists — the marketplace tree-browse route the model never had, plus the first HTTP surface forsearch.path()is derived from the same recursive CTEsearchuses, which removed the last live platform call from the marketplace read path;CategoryPathReaderstill serves the offer-build path. Two carve-outs are deliberate and worth knowing:destination/categorieswas NOT repointed — it resolvesProductMaster, a third taxonomy kind the projection does not model (its DTO also carriesdepth/active, which have no column), so Wave 3 cannot deleteCategoriesCacheServiceuntil that is modelled; andShopCategoryBrowseServicedelegation was deferred to #2085 because at that point it read live and there was no bootstrap-on-connection-create (#2084), so delegating would have left a freshly created shop’s category picker empty for up to an hour (both shipped in Wave 2c, below). Repointing also madeTaxonomySourceUnavailableException— raised by theresolveScopeevery taxonomy read funnels through — reachable from a second controller, so its HTTP mapping is a globalTaxonomySourceUnavailableFilter(422) alongsideCapabilityNotSupportedFilter(400) andConnectionExceptionFilter, not a per-controller catch: a local catch on the new controller would have left the repointed mappings routes answering 500 for a plain connection-configuration state. Wave 2b (#2075) surfacedsearchin the pickers, across all three category surfaces: the marketplace picker (bulk-category-choose-modal), the shop picker (shop-category-picker-modal), and the mapping-authoring picker (AllegroCategorySearch). One neutral hook (useCategorySearchQuery) serves all three, because the route resolves scope from the connection — which is also why it is deliberately not nameduseAllegroCategorySearchlike itsallegro*neighbours in that folder: those are the platform-named legacy this epic retires, and the shop picker is one of the three consumers. The hook owns the ≥2-character gate so no picker can forget it. The framing in the issue understated the defect: the marketplace picker’s input was already labelled “Search categories” while filtering only the currently-loaded level, so an operator searching from the root was toldNo categories match "…"about a category two levels down — a false statement about their own catalogue rather than a missing feature. Search renders as a sibling of the tree (replacing it while a query is active) rather than a mode onCategoryTreeBrowser: only one of the three surfaces uses that primitive, and itskey-remount breadcrumb-reset contract is precisely the state a search must bypass. Empty states are distinguished without an extra request — at root with zero browse nodes means “never synced”, anywhere else means “no matches” — because conflating them would surface the read model’s staleness as a broken search, the exact confusion the wave exists to remove. That inference is only sound where browse and search read the same store, which is why Wave 2b gave the shop picker a third'indeterminate'state rather than reusing it: its tree was still read live from the shop (the #2085 deferral) while its search read the projection, so a non-empty tree said nothing about whether the index had caught up. Claiming “nothing matched” there would have been a fresh instance of the very defect being removed. Wave 2c retired that state — see below. A search hit carries its own breadcrumb intoonSelect; deriving the path from navigation state would stamp the offer/product/mapping with a trail the category does not have. The now-unmountedcategory-picker.tsxthat #1741 superseded was deleted in #2130. Wave 2c (#2084 + #2085) closed the last live read.ShopCategoryBrowseServicenow delegates toIDestinationTaxonomyService.browseand survives only as the projection→ShopCategorymapping seam, soShopCategoryBrowser— like its marketplace counterpart — is reached solely by the sync job. Shedding the live call also shed a documented layering violation: core no longer constructs a NestJSUnprocessableEntityException, and the domainTaxonomySourceUnavailableExceptionreaches the #2074 global filter instead (still 422, but the body’serrorfield now names the domain exception). The two issues had to ship together — #2084 alone adds a guarantee nothing consumes, #2085 alone is a live regression. The bootstrap is a second enqueue point fordestination.taxonomy.sync, onConnectionServicebeside the existingenqueueInitialCatalogSync, fired unconditionally at create and on the transition intoactive; both are best-effort and can never fail the connection write. Three details are load-bearing. (1) Create is deliberately not gated on status —resolveScopecannot resolve an adapter for a disabled connection and so skips on its own, and encoding that rule twice would give it two places to drift; the enable trigger testsexisting.statusagainst the persisted result, neverpatch.status, so a name-only patch is not a transition. (2) The key is run-once per connection (bootstrap:{id}:taxonomy:sync, no timestamp), unlike the scheduler’s per-ticktaxonomy:owner:{owner}:sync:{ts}. The two therefore cannot share a key, so a same-minute overlap is prevented by the #2061 per-scope lock, not by dedup — and a re-enable after a disable collapses into the original key and enqueues nothing, which is correct becausedisable()does not delete projection rows. (3) A populated scope skips the enqueue, because the lock only prevents a concurrent walk: a second Allegro connection joining an owner synced an hour ago finds a free lock and would re-walk thousands of nodes. What the bootstrap does not do is guarantee a complete tree — the handler runs one page per job (SYNC_PAGE_LIMIT_DEFAULT = 500parent expansions) and does not self-reschedule, so it makes the walk start immediately rather than up to an hour later; a shop tree finishes in that first run, a marketplace tree still spans several ticks. That is also why the FE'indeterminate'state could be removed rather than merely re-justified: the propertyisTaxonomyUnsyncedneeds is a shared store, not a complete one. Mid-walk both halves are equally incomplete, so a node missing from search is equally unreachable by drilling, and the operator can never see something in one half the other denies. The shop picker’s empty-root copy changed for the same reason — it now says the tree has not synced yet instead of asserting the shop has no categories, a claim the UI is no longer in a position to make. Not retro-filled: a pre-existing shop connection that never synced shows an empty picker until the next hourly tick, since the trigger is a transition.CategoriesCacheServiceand its table are retired in Wave 3, and the deferred semantic half of MCP Phase 2 (browse_categories/search_categoriestools, #1488) is Wave 4. The job is connection-scoped as a documented interim scaffold becauseSyncJob.connectionIdis non-nullable, with the owner carried in the payload and “one run per owner” enforced by the scheduler electing one source connection per owner plus an owner-scoped idempotency key; removal is tracked as #1943. - Adapter-declared description format (#2193, ADR-046): what a destination accepts in a product description is declared BY the destination, not guessed by the caller.
DescriptionFormat(@openlinker/core/listings) carries allowed tags, per-tag allowed attributes, an optionalparent -> allowed childrencontent model, rewrites applied before the allowlist, block-opener and self-closing-void requirements, and a byte cap;applyDescriptionFormatis the single pure pass that enforces it, beside theapplyPricingRule/applyStockSafetyBuffer/checkRequiredToSellprecedents. The seam is asymmetric on purpose:getDescriptionFormat()is a member of the OPTIONALOfferFieldUpdatersub-capability - required of any adapter that declares it, but a marketplace adapter may ship no field-update capability at all, so a marketplace destination can legitimately declare nothing - and a REQUIRED member of theShopProductManagerPortbase port (a shop always publishes content, so it always has a grammar). The resolver (description-format-resolution.ts) still probes for the method at runtime rather than trusting the type: an out-of-tree plugin compiled against an olderlibs/coresatisfiesisOfferFieldUpdater(whose guard tests onlyupdateOfferFields) and would otherwise throw at publish time; it falls back to the conservative subset instead, and widening the guard was rejected because it would silently stop recognising such a plugin for field updates at all. Four write paths enforce it -OfferBuilderService,ProductPublishBuilderService,IntegrationsContentPublisherService(the Content tab’s channel publish, which reachesupdateOfferFieldswithout either builder) and the worker’sMarketplaceOfferFieldUpdateHandler- and a fifth is deliberately excluded:publishToMasterwrites to the catalogue of record, where narrowing would destroy the operator’s own content rather than protect a destination. The format is a publish-time contract, never a storage one: the stored draft keeps what the operator authored, and narrowing happens on the way out, which is why an italic mark is stored as<em>even for a destination that rewrites it to bold. Inbound is a separate, WIDER pass with a different job -sanitizeStoredHtml(@openlinker/shared/html) is the XSS boundary and keeps everything a real shop description carries (div,table, inline styles), because stripping those would rewrite the master catalogue.applyDescriptionFormatis explicitly NOT a security boundary. The frontend holds no destination knowledge at all:GET /listings/connections/:id/description-formatreturns the declaration plus adeclaredflag and aresolvedVianaming the capability that answered (null= no adapter could be resolved for the connection, which is a configuration state an operator can act on, not an undeclared destination), and the editor derives its toolbar, document schema and byte counter from the response - and every submit surface gates on the declaredmaxBytes, since an offer update or a bulk publish dispatches a job that would otherwise be rejected by the platform after the operator moved on - so a control exists because a destination declared a tag, and the pre-arrival state renders a loading surface with no toolbar (aria-busy) rather than authoring against a frontend-held default. A destination that declares nothing resolves to a conservative shared subset withdeclared: false, which the UI states rather than presenting as authoritative. - Bulk-wizard readiness: one category chain, honest blocker copy, and a batch-level precondition seam (#2240): a three-variant product listed one variant and blocked the other two behind a single chip reading
manual category. Five frontend defects sat behind that, and the shape of the fixes is what generalises. One chain, not two. Readiness derived the category from the variant tier while the submit pinnedoverride.overrides.categoryId ?? resolvedCategoryIdat the PRODUCT tier, so setting the shared category - the action the blocker banner itself recommended - could never clear a sibling’s chip: the wizard disagreed with its own submit.productCategoryIdOfis now the single chain read by the readiness check, the submit andnoCardCategoryIds, and that last consumer matters on its own, because a category pinned at the product tier previously never fetched its required-parameter schema, soallegro:needs-product-parameterscould not fire and the row readreadystraight into a 422. A blocker names its cause, and the effect is not a second cause:no-eansplits intono barcodeandinvalid barcode(add one versus correct the one you typed are different fixes), andcategory not setis deliberately NOT a blocker id - it is an editor-only consequence chip, because emitting it as an id would double the vocabulary and, on a destination that resolves the category server-side at submit, assert something false. Copy lives in onebulk-blocker-copy.tsper id, interpolating the offending barcode and the destination’s own display name, so the Review chip’s tooltip and the editor banner cannot drift and no marketplace name enters the host-neutral chip map. The outcome chain fails closed: an unrecognised category outcome emitsunknown-category-resultrather than falling through every arm and producing a ready row with no category - the shape a discriminant added backend-first would have taken - and that state is preserved across a reblock, since degrading it tono catalog matchwould assert the catalogue HAS no match for a barcode the lookup never answered about. It is modelled as its own member of a widerCategoryOutcomeunion rather than cast into the closed API one, because “this build did not understand the answer” is a fact about the wizard, not a shape the destination sends.OfferValidationContribution.validateBatchis the new plugin slot for a CONNECTION-level precondition (OfferBatchIssue), rendered as one banner for the batch and never as a per-row chip - a row cannot observe a connection-level fact, and repeating it per row would state N times something true once. It exists because Allegro’scollectMissingSellerDefaultsFieldsgate is the first statement ofcreateOfferand unconditional (card-linked offers are not exempt), so a connection missing a ship-from location, a responsible producer or safety information had every child job rejected while the wizard read all-green - theallegro:title-too-longfailure shape (#1962) one level up. Reporting a batch issue LOCKS the submit rather than warning: the precondition is deterministic and connection-wide, so no subset of the batch can succeed and a banner an operator can read past explains the wasted batch instead of preventing it. The cost of that lands on the plugin, and is stated in the type: report only what the destination DECLARES, and never let a mirror of a destination gate be stricter than the gate, or the wizard refuses a batch the destination would have accepted. Because the frontend cannot import the adapter, that mirror is a copy, kept honest byscripts/check-allegro-seller-defaults-mirror.mjsunderpnpm check:invariants- it compares thesellerDefaults.*path sets on both sides, so a fourth required group added adapter-side fails the build instead of silently under-reporting. - Pre-submit offer validation, two lanes (#2243): Allegro answers most of its rejections AFTER the operator submitted - some on the request, some once it validates the product card - and the operator reads them one record at a time in
Record failure detail. Most are knowable earlier from data the wizard already holds, so they are checked earlier. The split is not a style choice: the browser cannot see every value that reaches the marketplace. Values injected by attribute mapping rules (#1841) are assembled in the worker,apps/webhas no@openlinker/*dependency, and the only consumer of that projection is an MCP tool needing its own PAT - so drawing a Review chip for such a value would promise coverage that does not exist. Lane A (Review step) covers operator-authored values through the existingofferValidation.validateRowplugin seam (#1096):allegro:param-value-invalid(a value breaking a bound the CATEGORY declared),title-too-short(Allegro’s 12-character / 3-word floor, the other end of #1962’s ceiling, measured on the same sanitized title),no-photo,siblings-without-card, plus the ADVISORYean-unverified/in-store-barcode. A chip carries the field it is about (OfferBlockerDescriptor.field, declared by the plugin because the plugin owns the blocker) so clicking it opens the SAME edit modal focused on that field via adata-focus-fieldanchor -onFixalready routed tosetEditing, only the field was missing. Lane B (core, before the adapter call) runs the same rules over the projected half:AttributeProjectionServicecalls the purecheckParameterRestrictions(@openlinker/core/listings, besidecheckRequiredToSell) and returnsrestrictionIssues, which also turns its previously-silent dictionary miss into a reported issue - the offer used to publish without the value, which looks fine and is not (that miss is routed through the same checker rather than hand-written beside it, so a parameter carryingcustomValuesEnabledis never reported as breaking its own dictionary: upgrading a silent drop into a positive false claim would be worse than the debug line it replaced). Lane B is REPORTED-ONLY in this slice - the projection returns the issues and warn-logs them, and no builder gates on them, so a populated array means “observed”, never “publish refused”; the values it covers come from mappings the wizard cannot show or edit, so the checker had to be observable before it could be trusted to refuse a publish, and a consuming gate is a deliberate follow-up rather than an implementation detail. The FE mirror (features/listings/lib/parameter-restrictions.ts, theallegro-title.ts/required-to-sell.tsprecedent) is kept aligned byscripts/check-parameter-restriction-mirror.mjsunderpnpm check:invariants. Three rules shape what may block. (1) No bound is hardcoded - every limit is read offCategoryParameter.restrictions, since a constant here would repeat the very defect being closed (the marketplace declaredminLengthand we published anyway; the schema reached the UI only as an HTML attribute on an input whose save path never ran native validation). (2) A destination’s own declaration is a fact and blocks; OUR inference only warns - a barcode with no catalogue card may still be a perfectly licensed GTIN, soadvisory: truechips render without gating the batch, while restricted-circulation prefixes (02x,04x,2xx,98x,99x) are flagged and publication prefixes (977/978/979, ISSN/ISBN/ISMN) explicitly never are. Nothing ever suggests a recomputed GS1 check digit: that value is a DIFFERENT, possibly real GTIN belonging to another item under the same company prefix. (3) A failed schema fetch is a state, not a silence -useBulkRequiredProductParamsnow reportsfailedCategoryIdsand returns the fullschemaByCategory, because previously a settled-but-failed query leftisLoadingfalse with the key absent, which the wizard read as “do not block” and the required-parameter blocker vanished with no operator signal. Card-linked rows stay exempt from every product-section check (they inherit those values, and sending them anyway is its own rejection), and the multi-variantsiblings-without-cardblock exists because Allegro binds the new card to the first sibling it accepts and rejects the rest withProductConstraintViolationException.DataIntegrity- a product-level fact no single row can see. Deferred with reason: per-row granularity forenforceIdentifierRules(one bad check digit still fails a whole batch for every caller, not just this wizard) and connection-level seller defaults (implied warranty, return policy, GPSRresponsibleProducer+safetyInformation) as a batch-level precondition rather than a row chip. - Required-to-sell preflight (#1842): a publish can succeed on the destination while still being unbuyable there (a WooCommerce product missing weight/dimensions breaks live-rate shipping calc at checkout; zero stock with no backorder support can’t be purchased at all).
checkRequiredToSell(@openlinker/core/listings) is a pure, side-effect-free function — no I/O, reads only fields already resolved onto aPublishProductCommand-shaped input (stock,weight,commerce.dimensions) — that reportsRequiredToSellIssue[](OUT_OF_STOCK/MISSING_WEIGHT/MISSING_DIMENSIONS, each carrying aseverity).blockgates the publish exactly like the builder’s existing price/unresolved-parameter gates;warnis a soft, operator-overridable signal. Every rule shipped today iswarn— the severity field exists so a future hard-required rule (marketplace or shop) doesn’t need a shape change.ProductPublishBuilderService.buildPublishProductCommandruns the check on every shop publish and logswarn-level issues; the bulk shop-publish Review step (bulk-shop-review-step.tsx) runs the same stock rule client-side (a small FE-local mirror,checkShopLineSellability— no network call) against the resolved stock it already computes, surfacing an out-of-stock banner + per-row chip with a required “publish anyway” acknowledgement before submit (soft-block, not a late platform reject). The weight/dimensions rules are wired at the builder today; surfacing them in Review is blocked on the wizard gaining weight/dimensions input (tracked separately as WooCommerce field-completeness work in epic #1838) — the checker’s input shape already accepts them, so wiring is additive once that data exists. This is also the cross-cutting seam for a future marketplace-side check (e.g. delivery/return-policy completeness onCreateOfferCommand): sameRequiredToSellIssueoutput shape, a different pure input projection — not built yet, no marketplace requirement is scoped. - The category-resolve in-flight ceiling is adapter-declared and operator-visible (#2229, ADR-047 § Amendment): the streamed EAN resolve path paces its own outbound marketplace calls inside the adapter’s resolver, below the shared outbound limiter, so a connection with no
config.rateLimitand no manifestdefaultRateLimit(Allegro declares none, deliberately - #1810 §1) read as “not rate-limited” on the connection page while a fixed ceiling of 9 was in fact applied. The UI did not merely omit the number, it asserted the opposite. The adapter now declares it through the OPTIONALEanCategoryMatcherStreaming.getStreamConcurrency(), returning a neutralResolveConcurrencyCeiling(maxInFlight,source,adapterDefault), surfaced onGET /connections/:id/rate-limit-statusand rendered byRateLimitSection- on both theenabled: trueandenabled: falsepaths, thefalseone being exactly the connection whose ceiling was invisible (it is deliberately NOT onConnectionResponseDto, whosetoResponseruns per row on the connections list, so putting it there would construct N adapters to render a table). Four properties are load-bearing. Reported === enforced structurally: one pure function with two callers (getStreamConcurrency()and theconcurrencypassed into the stream), because a ceiling shown to the operator that drifts from the one applied is worse than the invisible ceiling it replaces - the operator would act on a number that is not true; the raw constant is unexported so a third call site cannot reopen that gap. Callers probe for the method rather than trustingisEanCategoryMatcherStreaming, which tests onlystreamCategoriesForBatchByEan: an out-of-tree plugin compiled against an olderlibs/coresatisfies that guard and would throw on a settings page, and widening the guard was rejected for ADR-046’s reason (it would silently stop recognising such a plugin for streaming at all). Discovery is manifest-first - building a capability adapter resolves credentials, sosupportedCapabilitiesis checked before construction, and an unadvertised capability, a construction failure and an absent method all yield no field rather than an error or a fabricated number; agetStreamConcurrency()that itself throws is the one case logged atwarn, since reporting an already-computed number cannot fail without a defect. The operator’sconfig.rateLimit.maxConcurrentclamps the ceiling DOWNWARD only - that knob is a safety valve on the operator’s own quota, and letting a generous value lift the adapter’s pacing would turn a cap into a throttle-release; a non-numeric (the column is JSONB, so the coercion lives in the one resolver rather than at the read), non-finite or non-positive value is ignored rather than honoured, since a zero ceiling would stall every resolve run silently. Both resolve paths go through that one clamp: the non-streamed batch collector keeps its narrower default of 3 (a caller blocking on the whole map gains nothing an operator can see from a wider count) but honours the cap too, so no resolve path is left neither clamped nor reported. - Per-line tax rate on an offer (#2245, ADR-063): the rate a marketplace stamps on an order line is decided long before the sale, when the offer is published - so the listings side owns propagation, not entry. The shop’s rate (projected onto
products/product_variantsat product-sync time, likeprice) is written onto the offer at create and at every update, exactly as price and stock already are. There is no OL-side rate field to type into, deliberately: a rate typed in OL would be a fourth source that no master or channel can be corrected from, and the whole chain exists so a fix lands where the catalogue lives. Two channel facts shape it. Allegro exposesOfferTaxSettings.rates[] = {rate, countryCode}andGET /sale/tax-settings?category.id=&countryCode=lists what a category allows; OL wrote neither before this epic, which is why offers published by OL report no rate at all on the resulting orders (live sandbox, 20 Aug 2026: 24 lines across 11 offers returnedtax: null, and aPATCHsetting the rates made the next purchase returntax: {"rate":"23.00"}). Erli’sProductCreate/Update.taxRateis the same required enum its order items carry, and its ownbuyableProblems.missingTaxRateblocks a rate-less product server-side. A category refusing the rate fails the publish with an actionable error naming the allowed values - never a publish with the rate quietly omitted, because when the marketplace says the category allows 23% and OL sent 5%, the shop record is almost certainly the wrong one. A frozen field (frozen.taxRate === true, #988 / #1737) is neither written nor reported as a recurring error - it is the signal that a human corrected the value, which is what makes mismatch attribution possible; Erli’sforceoverride stays deliberately unused. A shop-versus-channel disagreement never blocks: the shop wins, the document issues, and the mismatch surfaces on its own projection field with its own resolver - not as aSalesDocumentGateBlockReason, since the shippedinvoicingBlockedBadgesuppresses a badge whenever an invoice exists and a non-blocking conflict always has one. Every refusal on this path is behind one switch,OL_TAX_RATE_STRICT_ENABLED, off by default (#2260 review): catalogue coverage is zero on the day the epic deploys, so refusing every rate-less publish immediately would fail every child of every bulk batch - and unlike issuance a publish has no badge, no counter and no held state to read the reason from, just a failed batch. With the switch off both channel adapters publish with the rate omitted, byte for byte as before. The two refusals that survive the switch are the ones where the shop DID name a rate the channel cannot carry: an exemption code Allegro’s numericrates[]or Erli’s enum cannot express, and a rate the target category rejects. Those are conflicts at any coverage level, not coverage problems.
7. Sync Manager
Section titled “7. Sync Manager”See ADR-007 for the decision rationale.
-
Responsibility: Job scheduling and retry logic; workers execute jobs. Sync orchestration policies live in core application services (e.g., order ingestion, inventory propagation), not in worker handlers.
-
Key Services: SyncJobService, RetryService, SchedulerService
-
Location:
libs/core/src/sync/(core sync infrastructure),apps/worker/src/sync/(job runners/handlers),apps/worker/src/scheduler/(the cron scheduler, moved out ofapps/apiby #2279) -
Concurrency lanes (#2278, ADR-050): the worker runner schedules jobs through four lanes — workload profiles chosen by cost-of-starvation (
realtime | bulk | fiscal | fan-out), never by I/O shape or bounded context. A job’s lane is declared at handler registration (SyncJobHandlerRegistry.register(jobType, handler, lane)— a required parameter), and registration ends with a boot assertion that the partition covers everyJobTypeValuesmember: lane-aware claiming selects rows by"jobType" = ANY(<lane membership>), so an uncovered type would otherwise be silently stranded inqueued(worse than the loudmarkDeadan unhandled type used to get). Each tick every lane with free slots claims independently viafindAndLockDueJobsForLane(sameFOR UPDATE SKIP LOCKEDshape, additivejobType/scope arms — no schema change) and jobs run concurrently under per-(lane, scope) slot accounting,scope = connectionIdtoday via the pureresolveJobScopeseam (ADR-050 decision 3). Never strict priority: a saturatedbulklane (an operator’s 1000-child publish wave) cannot delay a queuedrealtimeorfiscaljob beyond its own lane’s availability. Scopes at their per-scope cap are excluded in the claim (their rows stayqueued— no lock/release churn), and the one case the exclusion cannot cover — a single claim returning several same-scope jobs — is trimmed post-claim, the surplus released back without an attempt penalty. Cap values (OL_LANE_{REALTIME,BULK,FISCAL,FANOUT}_CAP+_SCOPE_CAP, documented inapps/worker/.env.example) are env-overridable illustrative defaults until #1134 supplies per-lane measurements (ADR-050 decision 6) - with one exception,bulk, described next. A job’s lane depends on its TRIGGER, not only on its type (#2594, ADR-050 § Amendment).master.product.syncByExternalIdis a webhook-driven single-product sync someone waits on, and was also the child the catalogue sweeps enqueued a budget (100) wide per tick - so one type carried two costs of starvation, and the mapping was wrong in both directions at once: a catalogue cycle filled therealtimelane’s per-scope slots ahead of a buyer’s order, and the catalogue was throttled toOL_LANE_REALTIME_SCOPE_CAP(default 2), a cap sized for waited-on work. Measured on a live PrestaShop shop, raising the connection’s own rate limit from 60 to 300/min moved traffic from 50 to only 63 req/min - the limiter was never the ceiling. The fix keeps the lane declared per job type and splits the type:master.product.syncFromSweep/master.inventory.syncFromSweepare the sweep-triggered children, registered inbulkagainst the SAME handler instances as their…syncByExternalIdtwins. A fifth lane was rejected because ADR-050 decision 1 makes that a reversal gate, and both types stay registered so a child queued under the old type across the deploy still runs and the boot coverage assertion still holds; an operator can now also tell catalogue work from webhook work on the Jobs surface. Consequentlybulkis the first lane whose caps are measured rather than illustrative:total: 12, perScope: 8(from2 / 1), from an interleaved A/B run that sustained ~277 req/min while ~12 children ran concurrently on one connection, taking a 39 700-request catalogue from ~26.5 h to ~2.4 h. The p95 response ratios that run reported are withdrawn and must not be quoted - ADR-066 correction 1 records that the probe never checked HTTP status, so a fast error under load counted as a fast sample, and correction 2 records that store impact varies with cursor DEPTH rather than with catalogue size. The throughput figure stands; the store-impact figure does not, soOL_LANE_BULK_SCOPE_CAPshould be lowered for a shop whose own latency moves. Three limits on that number are load-bearing: it covers the PrestaShop catalogue read path only (lowerOL_LANE_BULK_SCOPE_CAPfor a slower shop); it speeds up every otherbulkjob type, the offer-create and shop-publish waves and the status-sync/backfill sweeps included, which is intended but is a global change; andperScopesits deliberately below the measured ceiling because decision 4 ships no round-robin fairness, so atperScope === totalone connection’s cycle could hold the whole lane.realtimeandfiscalkeep their untuned defaults - the point of moving the sweep child out is that the buyer-facing lane did not need raising.fan-outdid have to be raised, but for the opposite reason: not a wrong lane, a wrong SCOPE (#2609, ADR-050 § Amendment (#2609)).inventory.propagateToMarketplaceswas enqueued with a synthetic00000000-0000-0000-0000-000000000000connection id, so every stock write in the installation shared one scope, and afan-outper-scope cap of 1 then serialised all of them - measured on the demo stack as a queue growing ~145 jobs/h faster than it drained, with 15 066 rows backlogged from ordinary operation. The job now carries the master connection the stock was read from (IInventoryService.setInventory(item, sourceConnectionId)), which is what makes per-scope accounting mean anything, andfan-outdefaults tototal: 8, perScope: 4. The lane itself is confirmed, not changed: propagation makes no marketplace call of its own, and #2594’s split-by-trigger has nothing to separate here because a webhook and the inventory sweep both discover real stock drift - on a master with no stock webhook the sweep is the only thing that discovers it at all - so the cost of starvation is one cost, not two, and the job type stays single. The cap of 1 was sized for the lane’s other, cron-paced members; propagation is event-paced, one job per changed stock row. Raising it also lets two connections poll or enumerate concurrently, which the old cap prevented across the whole install, and it cannot multiply catalogue fan-out because each sweep additionally holds its own per-(kind, connection)SyncLockPortlock. The raise is a cap on database reads and child enqueues rather than on outbound requests, which is why it needed no per-destination measurement the waybulkdid. The all-zero placeholder id survives as a dormant fallback, and is now readable as one (#2745):InventoryServicestill stamps00000000-0000-0000-0000-000000000000when no source connection is supplied, andMasterDeletionToJobHandlerstamps it on everymarketplace.offer.pauseStalejob it enqueues - a job that genuinely has no owning connection. The frontend used to fetchGET /connections/:idfor it, take the 404, and renderUnknown, which is exactly what a deleted or broken connection renders as; it now rendersSystemwith no id and no link. The constant is hardcoded independently in all three places (the browser bundle cannot import@openlinker/core, #591), soscripts/check-system-connection-id-mirror.mjsunderpnpm check:invariantsfails the build if any copy drifts - otherwise the frontend special-case would silently stop firing and the row would quietly revert toUnknown. The caps bound one worker PROCESS, not the deployment: slot accounting is in-process, so N replicas multiply every effective cap by N — deliberately unlike the globally-enforced pg-bossgroupConcurrencyprecedent ADR-050 decision 3 cites, since cross-replica enforcement is out of scope for Wave 3 (multi-replica correctness is still the existingFOR UPDATE SKIP LOCKEDclaim’s job — only the pacing is per-process). Per-job machinery (retry ladder, heartbeat, rate-limit penalty-free requeue) is unchanged — stuck-job recovery moved out of the runner entirely in #2279, to themaintenancerole; the legacy un-lanedfindAndLockDueJobsis retained for its non-runner consumers. -
Worker roles (#2279, ADR-051): one worker artifact carries four selectable responsibilities —
jobs(intake + runner + handlers),events(domain-event stream consumers),scheduler(cron tasks),maintenance(stuck-job recovery) — chosen at boot viaOL_WORKER_ROLE(defaultall, so an existing single-process deployment is unchanged). Roles select MODULE IMPORTS, never runtime flags on a booted graph (AppModule.forRoles): a role that is off contributes no providers, no consumers and no timers, which is what makes “this replica cannot possibly run crons” a structural fact rather than a config claim. An unknown role name throws at boot rather than being skipped — in a split deployment a typo would otherwise produce a worker that silently carries nothing, surfacing hours later as missing cron output. Three consequences are load-bearing. (1) The scheduler is a fleet singleton, enforced by a lease, not by convention: it previously ran inapps/api, so scaling the api to N replicas multiplied every cron’s fan-out by N.SchedulerLeaseCoordinatorcompetes for asingleton:schedulerlock and drives the now-idempotentSchedulerService.start()/stop()off lease transitions, so exactly one process ticks and a dead holder is replaced within one lease TTL (OL_SCHEDULER_LEASE_TTL_MS, default 60 s, clamped[15 s, 10 min], heartbeated at TTL/3 via the newSyncLockPort.extendcompare-and-PEXPIRE). A Redis blip while holding is deliberately not treated as loss — only an explicitextend === falseis, since the lock is still there and the next tick retries inside the same TTL. (2) Coverage becomes the operator’s responsibility in a split deployment — nothing enqueues if no process carriesscheduler, nothing executes if none carriesjobs— so thejobsrole’s boot gate matters more than before. That gate already exists as ADR-050’sassertFullLaneCoverage(everyJobTypeValuesmember must have a registered handler+lane, or the worker boot fails naming the uncovered types) and serves ADR-051 decision 6 unchanged — a booting process can only assert its OWN coverage, never the fleet’s, which is why the deployment-level half stays with #2169. (3) The scheduler’s env vars are read by the worker now: an operator settingOL_PRODUCT_SYNC_CRONon the api gets no error and no effect (documented in place inapps/api/.env.example, which still enumerates the full task inventory). Stuck-job recovery moved out ofSyncJobRunnerintoStuckJobRecoveryService(maintenance); it needs no lease becauserequeueStuckJobsis a conditional UPDATE on a stalelockedAtand is idempotent across replicas. The api retains no@nestjs/scheduledependency at all — the one remaining api-side periodic task (demo-account cleanup) runs on an unref’d interval serialized by a per-ticksingleton:demo-cleanuplock. -
Two new inbound domains and one new job type (#2400):
InboundEventDomainValuesgrows by'fulfillment'(gatedFulfillmentExecutor) and'return'(gatedOrderSource), andfulfillment.work.statusSyncjoinsJobTypeValueson therealtimelane — ADR-050’s cost-of-starvation rule, since an executor’s progress report is waited on by a picker at a station, which outranks the “core-owned internal pass” instinct that would suggestbulk(that instinct is about who ENQUEUES the job; the lane is about who is hurt when it is late). Adding the job type is what makesassertFullLaneCoverage()fail at worker boot until it is registered — the guard doing its job, and the reason an uncovered type cannot be silently stranded inqueued. Not to be confused with the pre-existingmarketplace.fulfillment.statusSync(#834, shipping context). Full detail at § 26 Fulfillment. -
Per-connection sync-queue status (#2615): a read-only view answering “is this connection’s work getting done”, rendered on the connection detail page’s health tab via
GET /connections/:connectionId/sync-status. It lives insyncrather than in a third trust context (analytics-trust/catalog-trust): those two exist because they compose FOREIGN contexts that no single owner can answer for, whereas every input here -sync_jobs,connection_cursors, the backlog rule - already belongs tosync, so a wrapper context would add a hop and own nothing. It also resolves no adapter and makes no outbound call, which is a requirement rather than an optimisation: the diagnostics must answer precisely when the shop does not. Four properties are load-bearing. (1) The alert threshold is derived from the connection’s own drain rate, never a fixed number -alertThresholdJobs = drainRatePerHour * 24 h, measured over a one-hour observation window, because 500 queued jobs is a crisis on a shop that clears 20 an hour and twenty minutes of work on one that clears 1500. The ~145 jobs/h net growth the #2590 campaign measured is one seeded catalogue under a load harness on one machine, not a typical install, and is deliberately not encoded anywhere. (2) Alerting requires four conditions together: the queue is not converging (arrival > drain), the DUE queue holds more than the derived threshold AND more than an absolute floor (BACKLOG_MIN_ALERT_JOBS), its oldest due job has already waited longer than the alert horizon, and something actually succeeded inside the window. The wait condition keeps the alert honest about sweeps - an operator triggering a full sweep enqueues thousands of jobs in one minute, satisfying the first two while nothing is wrong, so a fresh deep queue readsgrowingand only becomesbackloggedonce the wait is genuinely a day old. The floor and the measured-drain condition keep it honest about retries: a failing job is requeued toqueuedwith a futurenextRunAtand its originalcreatedAt, so with a measured drain rate of 0 the derived threshold is 0 and “more than the threshold” degenerated into “the queue is not empty” - one retrying job on a quiet connection raised the red banner while the worker was healthy. Queue depth therefore counts only DUE jobs (nextRunAt <= now), with backing-off jobs reported separately asdeferredCount, because a job waiting on its own schedule is not queue depth. An alert that fires on a healthy install is worse than no alert. (3) Vocabulary follows thecatalog-truststandard:idle | draining | growing | failing | backlogged | unknown, whereunknownmeans the counts could not be read (never a healthy-looking zero), and a clearance estimate isnullwhen the queue is not converging rather than a very large number dressed up as an estimate.failingexists because only success is drain: countingdeadas drain gave a connection whose every job fails fast a high drain rate, a high threshold and an empty queue, so it read green while nothing worked at all. Nothing succeeded in the window plus at least one death now readsfailingwhatever the queue depth is. A connection holding no cursor reportsnulland is described as normal for a webhook-fed connection, not flagged. (4) The attempt-duration mean excludes NULLlastAttemptDurationMs(#2611) and reports its own non-null sample size beside it - the column is null on every row predating its migration, so counting those as zero would understate every real duration - and the read offers no SUM at all, because the column describes ONE attempt and a total time spent syncing cannot be built from it. Read cost is two bounded aggregates plus one single-row cursor read. The split is what bounds it - and it bounds THIS READ, never the table: retention forsync_jobsis owned by #2946 since the #2886 sizing audit, which found that the programme-wide persona change to ~1000 orders/day (product-spec-oms-wave3b-scan-pick-pack.md§ 1.1) adds an ORDER-driven growth axis beside the sweep-driven one this paragraph describes, of the same order of magnitude, where at the previous persona it was negligible.sync_jobshad no retention anywhere in the tree until #2946 - before it, a single unbounded aggregate touched every row the connection ever had - millions inside a year on a connection taking sweep children every twenty minutes. -
sync_jobsretention (#2946, D16):SyncJobRetentionService(apps/worker/src/maintenance/, themaintenanceworker role, ADR-051) deletes TERMINAL rows in budgeted batches -succeededrows older thansyncJobRetentionDays(Operational Settings, default 30) anddeadrows older thansyncJobDeadRetentionDays(default 90), both bounded 30-365 days and reported with the rung that produced them (setting | env | default), the same shape every other Operational Settings knob uses. Three properties are load-bearing. A positive two-value whitelist, never an exclusion list (the ADR-049/#2604 outbox-retention shape, applied here):SyncJobRepositoryPort.pruneTerminalJobs’sstatusparameter is typedSyncJobRetentionStatus = 'succeeded' | 'dead', soqueued/runningrows are unreachable through the TYPE, not merely by convention - a runtime guard refuses any other value too, in case the type is bypassed. Budgeted batches, never one unbounded DELETE: Postgres has noDELETE ... LIMIT, so each batch isDELETE ... WHERE id IN (SELECT id ... ORDER BY "updatedAt" ASC LIMIT batchSize)(1000 rows), and one hourly tick issues at mostMAX_BATCHES_PER_RUN(50) batches per status - a backlog larger than that drains across several ticks rather than holding a long transaction against a table every other sync-job write also contends for. A per-runSyncLockPortlease, unlike the idempotentStuckJobRecoveryServiceit shares themaintenancerole with: this pass loops several DELETE statements per tick, so twomaintenancereplicas racing the same rows is wasted work rather than a correctness bug, and the lease covers ONE RUN (never a renewed leadership term) - a lost lock degrades to a skipped tick.IDX_sync_jobs_status_updatedAtbacks the oldest-first sub-select;updatedAtis the row’s last write, which for a terminal row is the transition into that status itself. The live-queue aggregate is restricted tostatus IN ('queued','running'), so it scales with the live queue rather than with history and is served by the(status, nextRunAt)index; the historical aggregate is bounded bycreatedAt >= now - BACKLOG_HISTORY_WINDOW_MS(7 days, far longer than the retry ladder’s ~2-day maximum, so no job can complete outside it and be missed as drain) on the(connectionId, createdAt)index.deadCountis windowed the same way, so an old death clears with age instead of keeping a note on the panel forever. Response size is independent of job count either way. -
A poison stream entry now has a terminal state (#2301, D48, ADR-049 § Amendment (#2301, D48)): the two Redis-Streams consumers that still carry a PEL -
job-intakeandmaster-deletion-offer-pause- previously retried a poisoned entry forever, alarming once via an in-memory, per-processRecoveryAttemptTrackerwhose count reset on every worker restart. The counter is now Redis-backed (INCR poison:{stream}:{group}:{id}, 7-day TTL - well above the ~50-minute worst-case drain horizon), so the attempt count survives a restart. PastMAX_RECOVERY_ATTEMPTS(still 10), the entry is written to the durablestream_dead_letterstable (libs/core/src/events/, since it completes the consume half of this repo’s one publish/consume Redis-Streams pair) and only thenXACKed - a failed write leaves the entry pending and it is retried on the next recovery pass, never silently dropped.raw_fieldsis stored asjsonb, exactly asXRANGE/XPENDINGhand the entry back, since neither consumer can construct a typed payload from a raw pending entry. Operator-visible as a read-only list + count on Diagnostics > Jobs & Logs (admin/operator/viewer); no replay action in this pass. This closes ADR-049’s “Known gap” by the durable-terminal-state route rather than by a confirmed production poison-entry observation, which this repository’s own tooling still cannot provide - see the ADR for the full reasoning. Removing thejob-intakestream hop entirely (the transactional-work-row treatment #2280 gave the webhook path) remains the long-term direction. -
Penalty-free deferral, and the bound that keeps it finite (#2613 / #2617): some failures are not the job’s own. A destination throttling us (429), a destination under maintenance (503), a queued rate-limit slot that timed out, or a write refused because a peer held the serialisation lock - none of those is evidence the job is broken, so each requeues without consuming a retry attempt (
requeueWithoutPenalty), through one path in the runner. A platform adapter reports the first two through the optionalRetryClassifierPort.getRetryDeferral, probed rather than trusted (the ADR-046 shape) and coerced at the registry, which ignores a non-positive or non-finite delay and clamps anything above one hour so a buggy plugin cannot park a job for decades. The coreContendedWriteErrorcovers the last one, recognised in core because no platform classifier could. Four properties are load-bearing. The path is bounded, or a job would be immortal: a deferral consumes no attempt, so a shop answering 503 for ever would recycle its jobs for ever -queued, holding that connection’s lane scope slots, neverdead.sync_jobs.deferredTotalMsaccumulates the deferral wait GRANTED to the job, and once it would pass the budget (OL_JOB_MAX_DEFERRED_WAIT_SECONDS, 24 h) the job rejoins the ordinary retry ladder and can reachdead. A cumulative budget rather than a deferral count, because the grants differ by an order of magnitude (60 s for a throttle, 300 s for maintenance) and a count would mean a different amount of patience per reason. A deferral never beats a terminal answer: the deferral check runs only whenisNonRetryablesaid no, because both registry methods OR across every registered classifier, so a cause one plugin calls deterministic and another reports as deferrable must die rather than defer for ever. A deferred job is visible as deferred, not merely as queued: the reason and the spent budget are written intolastError, and the jobs list and detail page saydeferredbeside the attempts counter that a deferral deliberately leaves standing still. The duration recorded is honest about what ran: a 429 or 503 means the handler executed and spent real time before the destination turned it away, so that path writeslastAttemptDurationMs, while the paths where nothing executed (a rate-limit slot that never opened, a job killed for having no registered handler) CLEAR the column rather than leaving an earlier attempt’s number beside a new state. -
Status vs outcome (#391 / #400):
sync_jobs.status(queued | running | succeeded | dead) tracks orchestration.sync_jobs.outcome('ok' | 'business_failure' | null) tracks the business result, set only on the succeeded path. EachSyncJobHandler.execute()returns aSyncJobHandlerResultwhoseoutcomethe runner persists viamarkSucceeded(id, outcome)— atomic with the status flip.OfferCreationExecutionServiceis the first orchestrator to derivebusiness_failurefrom a terminal-rejection branch; other handlers return'ok'mechanically until they grow their own domain-failure semantics.
8. Event Bus / Messaging
Section titled “8. Event Bus / Messaging”- Responsibility: Event-driven communication between modules
- Technology: Redis Streams (initial), RabbitMQ/Kafka (future)
- Location:
libs/core/src/events/
9. Identifier Mapping Service
Section titled “9. Identifier Mapping Service”See ADR-004 for the decision rationale.
- Responsibility: Centralized identifier mapping between external platform IDs and internal OpenLinker IDs
- Key Services: IdentifierMappingService
- Location:
libs/core/src/identifier-mapping/ - Key Features:
- Generates unique internal identifiers for all entities (single seed across entire system)
- Maps external platform identifiers to internal OpenLinker identifiers
- Context-aware mapping (entity type, platform, etc.)
- Used by adapters to replace external IDs with internal IDs during data transformation
- Architecture: Core infrastructure service used by all adapters
- A connection is archived, never deleted (#3657):
connections.statusgainsarchived, a soft delete reachable only fromdisabledthroughPATCH /connections/:id/archive, which also deletes the stored credential and clearscredentialsRefto''. Hard delete is the wrong tool for the reasonConnectionPort.disablealready states:connectionIdis the namespace of every identifier mapping, and seven mapping and rule tablesON DELETE CASCADEfrom the row. Three properties are load-bearing. (1) The exclusion lives in ONE place -ConnectionPort.list()without astatusfilter leaves archived rows out, so every picker, sweep, scheduler task,listCapabilityAdapterscall (includingincludeAllStatuses) and authority read drops them with no call-site change, whileget(id)stays unfiltered so history still resolves the connection’s name. A new read that bypasses the port would re-expose them. (2) An archived row leaves only throughrestore, which returns it todisabled: a plainPATCHmay neither setarchived(it would skip the credential wipe) nor change an archived row’s status (it would re-enable a connection whose credential is gone), andPUT /credentialsrefuses an archived row too.getAdapterrefuses it withConnectionDisabledException, so a stray job or history link fails loudly. (3) Credential re-entry after a restore goes throughPUT /credentials, which mints a fresh row when the ref is empty;credentialsBackedtherefore means “writable viaPUT /credentials” (a stored credential, or an empty slot on an adapter that needs one) and the newcredentialsStoredsays whether a value is actually present. Authority claims held in an archived row’sconfigstop counting because the row leaves every candidate set; nothing rewrites that config. A connection another one still points at cannot be archived: a reference resolved by enumeratinglist()loses an archived referent while one resolved throughget()keeps it, andconfig.masterCatalogConnectionIdis list-resolved (the mapping pages find the paired catalog in the connections list), soarchive()refuses withConnectionInUseException(409,reason: 'master-catalog-referenced',referrers: [{ id, name }]) while any non-archived connection names it there. The reference is a JSONB value with no FK, so the refusal lives in the service, theLocationInUseErrorprecedent (#2316).fulfillment_routing_rules,routing_decisions.routerConnectionIdand the authority claims resolve throughget()or capability listings and need no refusal. An adapter may forbid archiving throughAdapterMetadata.archivable(absent =true, read viaresolveArchivable), and the OL-OMS declaresfalse: its row is created when an operator enables the OMS (ADR-055) and carries the routing authority claims, so it can be disabled - which keeps it visible and one click from undone - but never hidden. The rule is a manifest field rather than aplatformType === 'openlinker'check, for the same reasonrequiresCredentialsis (ADR-055).
10. Plugin Manager / Integrations
Section titled “10. Plugin Manager / Integrations”See ADR-003 for the decision rationale.
- Responsibility: Adapter registry, per-connection adapter resolution, capability validation, registries for cross-cutting per-adapter capabilities (connection-test, webhook provisioning, connection-config + credentials shape validation).
- Key Services: IntegrationsService, AdapterRegistryService, ConnectionService, ConnectionTesterRegistryService, WebhookProvisioningRegistryService, ConnectionConfigShapeValidatorRegistryService, ConnectionCredentialsShapeValidatorRegistryService.
- Location:
apps/api/src/integrations/(API layer),libs/core/src/integrations/(core domain). - Webhook provisioning (#583):
WebhookProvisioningPortdefinesinstall(connectionId, actorUserId?)returning a neutralWebhookProvisioningResult(webhooksConfigured,testPingTriggered, optionalwarning). Each integration package self-registers its adapter againstWebhookProvisioningRegistryServiceinonModuleInit(today onlyPrestashopWebhookProvisioningAdapteratprestashop.webservice.v1).ConnectionService.installWebhooksis the single dispatch layer: it resolves the connection’s adapter viaIntegrationsService.resolveAdapterMetadata, looks the provisioner up byadapterKey, and returns 400 if no provisioner is registered.ConnectionController.installWebhooksis a thin pass-through — the API package boots without any PrestaShop-specific binding, which keeps Modularity Thread E HIGH closed. - Token-in-URL webhook auth (#3644, ADR-021 amendment): a provider whose signature could not be verified (Shoper, at the time) authenticates by
?token=<secret>. Shoper’sx-webhook-sha1has since been resolved (sha1(webhook_id:secret:rawBody)), and its decoder now requires BOTH the URL token and the signature. Only the signature is keyed by the per-connection secret itself: the URL no longer carries it, because the shop panel lists every webhook URL in plain text and the URL lands in reverse-proxy and tunnel access logs, and a leaked URL must not let its reader sign a delivery. The provisioner registers?token=hex(HMAC-SHA256(secret, "shoper-webhook-url-token:v1"))instead, through onederiveShoperWebhookUrlTokenpolicy the decoder recomputes it with, so the two cannot drift. A webhook registered before the derivation still carries the raw secret as its token and is still accepted, once its signature verifies, with a warning naming the remedy (re-running webhook install, which rotates the secret and registers the derived token); refusing it would close nothing the signature check does not already require, while silently cutting every not-yet-reinstalled connection off its webhooks. The controller passes the query, reduced to first string values, to the decoder’sverifythrough the optionalqueryfield. It reaches nothing else and must never be logged. Install registers every matching shop webhook with the rotated token;shoper-orders-pollbackstops the window after a rotation and must stay enabled. - Connection config & credentials shape validation (#586 / #587):
ConnectionConfigShapeValidatorPortandConnectionCredentialsShapeValidatorPortdefinevalidate(payload): Promise<void>, throwingInvalidConnectionConfigException/InvalidCredentialsShapeExceptionfromlibs/core/src/integrations/domain/exceptions/with a flat list of{ path, message }issues. Each integration package self-registers its validators against the host’s two registry services inplugin.register(host)— today Allegro registers a config-shape validator atallegro.publicapi.v1(credentials shape is enforced deeper, atAllegroAdapterFactory.resolveCredentials), and PrestaShop registers both.ConnectionService.create/update/updateCredentialsare the single dispatch layer: they callIntegrationsService.resolveAdapterMetadata, look the validator up byadapterKey, and short-circuit when no validator is registered (closing the previous platform-switch inapps/api/src/integrations/application/services/util/). The API maps the domain exceptions toBadRequestException({ message, errors })at the boundary; plugins never depend on@nestjs/common. The structuralValidationErrorLiketype inlibs/corekeepsclass-validatorout of the core runtime — the function consumes the shape, and plugins that opt into class-validator-based DTOs depend on it from their ownpackage.json. - Static manifest export (#575): every adapter package exports its
AdapterMetadataas a top-levelconstfrom the package barrel —allegroAdapterManifestfrom@openlinker/integrations-allegro,prestashopAdapterManifestfrom@openlinker/integrations-prestashop,erliAdapterManifestfrom@openlinker/integrations-erli(#980 — shipssupportedCapabilities: []; #984/#993 addOfferManager/OrderSourcetogether with the adapters that deliver them). The runtime plugin descriptor (createAllegroPlugin(deps).manifest) returns the same reference, so static and runtime views can’t drift. The static export is the seam for future host-side tooling — manifest-diff CLIs, capability-matrix dashboards, compatibility checks at boot — that needs to readadapterKey/supportedCapabilities/versionwithout instantiating the plugin (which would require resolving its cross-package deps).- Advertised-without-dispatch sub-capabilities: a manifest capability is not always backed by a
dispatchCapabilitytable entry. FinerOfferManagersub-capabilities — Allegro’sCategoryBrowser/EanCategoryMatcher(#1367), Allegro’sOfferCreator/OfferEventReaderand Erli’sOfferCreator(#1498) — are declared insupportedCapabilitiespurely so host-side discovery (the connection response) can tell them apart, and are resolved only by narrowing the dispatchedOfferManageradapter with itsis*guard (isCategoryBrowser,isOfferCreator, …), never viagetCapabilityAdapter(connectionId, 'OfferCreator')directly. CallinggetCapabilityAdapter/listCapabilityAdapterswith an advertised-without-dispatch name still passes the manifest gate but then fails insidedispatchCapability(a genericError, notAdapterNotFoundException— in the list path that aborts the whole listing instead of skipping the connection,integrations.service.ts). No current caller does this; the pattern is safe as long as call sites keep resolving these sub-capabilities through the guard, not the registry. - The pattern is not
OfferManager-only (#2329):ReturnSourceReader— the returns half ofOrderSourcePort(listReturnFeed+getReturn, ADR-060) — is the firstOrderSource-family member declared this way. Same rule, same failure mode: it is resolved by narrowing the dispatchedOrderSourceadapter withisReturnSourceReader, never viagetCapabilityAdapter(connectionId, 'ReturnSourceReader'), and it is absent fromCoreCapabilityValues. The contract shipped ahead of its only implementer (Allegro, #2330, same wave), which also amended it with the optionalterminalRawStatuseshint — the guard is unchanged, so an adapter compiled against the pre-amendment shape is still a fullReturnSourceReader.
- Advertised-without-dispatch sub-capabilities: a manifest capability is not always backed by a
- Type-safe capability dispatch (#573):
@openlinker/plugin-sdkshipsdispatchCapability<T>(capability, table, pluginName). PlugincreateCapabilityAdapterimplementations declare a typed dispatch table{ OfferManager: () => offerManager, OrderSource: () => orderSource }and the helper returns the matching adapter cast toT. The cast lives in one place rather than once percaseper plugin. True compile-time enforcement ofcapability ↔ Tis blocked by the open string-set design (#576) — plugins can register new capability names at runtime, so a closedRecord<Capability, AdapterPort>keyed by literal capability names can’t exist statically. The helper is the right size for the problem: it deletes the per-caseas unknown as Tboilerplate and gives plugin authors a uniform error message format (“{pluginName} adapter does not support capability: {capability}. Supported capabilities: …”, rendering<none>when the dispatch table is empty).
11. Logging & Monitoring
Section titled “11. Logging & Monitoring”- Responsibility: Structured logging, metrics, tracing
- Contract:
LoggerPort(log/debug/warn/error) shipped from@openlinker/shared/logging. The consumer-facingLoggerfactory (new Logger(ClassName.name)) delegates to a process-wide active backend. - Backends:
ConsoleLoggerAdapteris the zero-dependency default that ships in the same package, so plugins compiled against@openlinker/sharedget working logs without any host wiring. The NestJS-backedNestLoggerAdapterlives on the host-only subpath@openlinker/shared/logging/nest; hosts callinstallNestLogger()at the top ofbootstrap()to swap it in (#589). Plugins never transitively pull@nestjs/commonthrough the logger. - Tracing: OpenTelemetry (future).
- Location:
libs/shared/src/logging/(port + default + factory);libs/shared/src/logging/nest/(Nest-backed adapter, host-only).
12. Content
Section titled “12. Content”- Responsibility: Per-product, per-channel (or master) content fields with draft write-through and conflict detection. First field key:
description. - Key Entities:
ProductContentField - Location:
libs/core/src/content/ - Capability: Uses
ContentPublisherPortfor outbound publishing. Master path resolvesProductMasterPortvia the integrations registry and callsupdateProductwith a keyed patch. Channel path resolves anOfferManagerPortfor the target connection, requires theOfferFieldUpdatersub-capability, walks the product’s variants →OfferMappingRepositoryPort.findMany→ distinct external offer IDs, and issues oneupdateOfferFieldsper distinct offer (Allegro TEXT-section payload; idempotency keycontent:{productId}:{connectionId}:{publishTimestamp}). - AI suggestion:
ContentSuggestionServicecomposesIIntegrationsService(fetch product + variants) +IPromptTemplateService(render theoffer.description.suggesttemplate for the current channel) +AiCompletionPort(generate). Bound in the API layer (apps/api/src/content/content.module.ts) becauseAI_COMPLETION_PORT_TOKENis only provided whereAiIntegrationModule.register()is registered. - Storage:
product_content_fieldtable with two partial unique indexes (master vs channel) to honour Postgres’ NULL-distinct uniqueness for the nullableconnection_idcolumn. - Conflict model: optimistic — inbound reconcile sets
has_conflict=truewhen an external version diverges while a draft is pending; re-saving the draft is treated as implicit acknowledgement and clears the flag.
13. AI
Section titled “13. AI”- Responsibility: Provider-agnostic LLM completions for content generation, plus editable prompt-template storage (versioned draft/publish lifecycle) consumed by the suggestion flow.
- Key Port:
AiCompletionPort(complete(input) → result). - Key Entity:
PromptTemplate(libs/core/src/ai/domain/entities/prompt-template.entity.ts) — one row per(key, channel, version), stateful (draft | published | archived). Thechannelaxis is open-world (#580):PromptTemplateChannel = stringcarries any platform connection’splatformType, mirroring the same open-world shape used for capability (#576) and platformType (#578/#579). Plugin authors register templates against new channels without editing core. The DTOs use@IsString() @IsNotEmpty()(matching theplatformTypeprecedent inCreateConnectionDto); the controller maps the'master'query sentinel tonulland forwards every other non-empty string verbatim. FE picker and list rendering are registry-driven viausePlugins(). - Key Service:
PromptTemplateService(libs/core/src/ai/application/services/prompt-template.service.ts) — CRUD, publish (archives previous published row transactionally), revert (clones a historical version into a new draft), render (renderTemplatepure helper substitutes{{dotted.path}}placeholders with strict-required / optional / passthrough semantics). - Location:
libs/core/src/ai/. - Adapter package:
libs/integrations/ai/(workspace@openlinker/integrations-ai) — registers oneVercelAiCompletionAdapterinstance per supported provider (anthropic via@ai-sdk/anthropic, openai via@ai-sdk/openai) plusFakeAiCompletionAdapterfor tests / offline dev.AI_COMPLETION_PORT_TOKENresolves toMultiProviderAiCompletionAdapter, a router that reads the active provider on every call and delegates to the matching per-provider adapter. Anthropic-specific cache-control on the system message is gated toprovider === 'anthropic'so the OpenAI request never carries a strayproviderOptions.anthropicblock. - Selection (#451 / #452): the active provider is a runtime setting persisted on the singleton
ai_provider_active_settingtable. Resolution: DB row →OL_AI_PROVIDERenv (first-boot fallback) →'anthropic'default. Admins switch the active provider viaPUT /ai-provider-settings/active; the router reads the setting through-the-DB on every completion (no in-process cache — singleton-row PK lookup is sub-millisecond). Per-provider keys live atref = ai-provider:{provider}in the encryptedintegration_credentialstable;CredentialsAiProviderAdapterretains a 60 s per-provider key cache. - Admin surface:
PromptTemplatesControlleratapps/api/src/ai/http/prompt-templates.controller.ts;AiProviderSettingsControlleratapps/api/src/ai/http/ai-provider-settings.controller.tsexposesGET /ai-provider-settings,PUT /keys/:provider,DELETE /keys/:provider,PUT /active(all@Roles('admin')). FE admin UI at/ai/prompt-templatesand/ai/provider-settings. - Storage:
prompt_templatestable with four partial unique indexes honouringNULL-distinct semantics on the nullablechannelcolumn (version uniqueness + “at most one published per(key, channel)”). - Telemetry: per-completion structured log
{ requestId, model, latencyMs, inputTokens, outputTokens, cachedInputTokens }; publish / revert actions log{ templateId, key, channel, version, actor }. - Worker registration (#737):
AiIntegrationModule.register()is registered inapps/worker/src/plugins.tsso the bulk-flowmarketplace.offer.createhandler can callContentSuggestionService.suggestDescription()per-job for AI-generated offer descriptions.AI_COMPLETION_PORT_TOKENresolves throughPluginRegistryModule.forRoot({ plugins: workerPlugins })in the worker’sAppModule. The suggestion binding lives atapps/worker/src/content/worker-content.module.ts(mirrorsapps/api/src/content/content.module.ts) — it cannot live inlibs/core/src/content/content.module.tsbecause that would force core to value-import@openlinker/integrations-ai, reversing the core → integration dependency direction. - Channel resolution and the absent fallback (#2202):
channelis an exact-match key, never a hint —PromptTemplateRepository.findLatestPublisheddoes a plainfindOneon{key, channel}andPromptTemplateService.renderthrowsPromptTemplateNotFoundExceptionon a miss. There is no channel→master fallback anywhere in this chain.MarketplaceOfferCreateHandlerresolveschannelfrom the offer’s own connection (IIntegrationsService.getAdapter(connectionId).connection.platformType, a metadata-only lookup — never a hardcoded literal, since the handler serves every marketplace, not only Allegro) andShopProductPublishHandlerhardcodes'woocommerce'because it only ever serves WooCommerce today. In both handlers, a platform with no published(offer.description.suggest, channel)row throws insidesuggestDescription, and the handler’s owncatchis what makes that non-fatal — it falls through to the operator’s override or the builder’s master-description default, exactly as an LLM-call failure would. A missing seed is therefore not a degraded suggestion, it is no suggestion at all. Every platform an operator can enable AI generation for needs its own seeded, published row (see theallegro/prestashop/erli/woocommerceseeds above) — there is no wildcard or default row that stands in for an unseeded platform. - Shop-publish AI descriptions (#1840): the outbound shop-publish path (
shop.product.publish) reuses the same seam — theShopProductPublishHandler(worker) callsContentSuggestionService.suggestDescription({ channel: 'woocommerce', ... })to fillcontent.descriptionwhen the operator setsgenerateDescriptionon the single/bulk shop-publish submit (threaded viaEnqueueProductPublishInput→ShopProductPublishPayload). Same placement + precedence as the offer flow: an explicit operatorcontent.descriptionoverride always wins (never overwritten), and any AI failure (variant lookup, missing template, LLM error) logs a warning and falls through to the master description. Theoffer.description.suggestprompt template is seeded per channel; #1840 adds thewoocommerceseed alongside the existingallegro/prestashoprows. The FE toggle lives in the WooCommerce publish wizard’s Configure step; a per-row override awaits the #1830 publish edit modal (the payload + worker already honor a per-item explicit description).
14. Invoicing
Section titled “14. Invoicing”See ADR-026 for the country-agnostic design.
- Responsibility: Issue fiscal documents for orders, optionally submit them to a tax authority for clearance, and reconcile the authority-assigned identifier + receipt. Country-agnostic by construction — no
NIP/KSeF/FA/VAT-specific vocabulary lives inlibs/core; all national specifics are confined to the provider adapter package. - Key Entities:
InvoiceRecord(neutral projection of an issued document), value typesDocumentType,InvoiceStatus(pending | issued | failed),RegulatoryStatus(not-applicable | submitted | cleared | accepted | rejected). - Location:
libs/core/src/invoicing/. - Capability:
InvoicingPort(base —issueInvoice/getInvoice/upsertCustomer/getSupportedDocumentTypes) plus composable sub-capabilities indomain/ports/capabilities/, each with a co-locatedis*guard:RegulatoryStatusReader— read the clearance status of a previously-submitted document.RegulatoryTransmitter(extendsRegulatoryStatusReader) — submit a document for clearance + read its status.getSupportedDocumentTypes()is value-level discovery (which neutral types a provider can issue), distinct from the method-bearing sub-capability.RegulatoryDocumentReader(#1224) — retrieve the authority’s confirmation document (e.g. the PL UPO) or a rendered view for a cleared document, as a neutralRegulatoryDocumentblob.BankAccountsReader(#1303 follow-up) — list the seller’s payable bank accounts known to the provider (neutralInvoicingBankAccount), backing the live picker for Transfer invoices.BankAccountDefaultSetter(extendsBankAccountsReader, #1303 follow-up) — mark an account as the provider’s own default so it stays in sync with the account OL stamps on Transfer invoices.RegulatoryResubmitter(#1356) — re-trigger transmission of an ALREADY-ISSUED document to the tax authority, for the operator “resend to KSeF” action on a document whose clearance ended inrejected. Kept flat (does notextends RegulatoryStatusReader) — it backs a NATIVELY-transmitting provider (e.g. inFakt) that needs an explicit resubmit kick from OL, distinct fromRegulatoryTransmitter(OL itself holds the authority session).PaymentStatusReader(#1354) — authoritative re-read of a document’s payment state; a provider payment webhook is only a trigger, core always re-reads via this capability rather than trusting the webhook body.PaymentMarker(#1362) — push an authoritative “paid” state to the provider for an order settled elsewhere (e.g. a marketplace order the buyer paid off-platform, so the provider’s own bank-statement matching has nothing to reconcile against).InvoiceEmailSender(#1353) — trigger the provider to render and email the already-issued invoice to the buyer.CorrectionIssuer(#1229) — issue a correction (IssueCorrectionCommand) of an already-issued document.IssueCorrectionCommand.originalDocumentis an OPTIONAL, caller-assembled reconstruction (buyer/currency/lines/clearance linkage) for adapters that cannot correct via a per-line delta and must resubmit a complete document (KSeF’s FA(3) KOR, #1288); adapters that only need deltas (Subiekt) ignore the field. Issuance-time line snapshot (#1297):InvoiceRecordpersists a neutralissuedLineSnapshot({ buyer, currency, lines }) whenever a document is issued (from the issue command) or corrected (the correction’s own post-correction lines) — captured in the coreInvoiceService, so no adapter change. The HTTP controller assemblesoriginalDocumentfrom that snapshot on the document being corrected, so theoriginalLineNumber-indexed deltas diff against the lines as issued (and, for a correction-of-correction, against the prior correction’s own lines) even if the order changed since. Only records issued before the snapshot column existed fall back to rebuilding from the order’s current state (the pre-#1297 path, with itsbuyerTaxId: null+ line-fidelity caveats). The returns-side consumer refuses that fallback (#2374):ReturnCorrectionProposalServicereports a namedno-line-snapshotoutcome rather than diffing a pre-snapshot document against the order’s current state, and it targets the latestissuedrecord viaIInvoiceService.getLatestIssuedInvoiceForOrder— status-agnosticgetLatestInvoiceForOrderwould let a newerfailedorpendingrow mask the document that actually exists. See § 22 Returns.
- The buyer’s tax number on an issued document is FROZEN at issue, never joined back from the order (#3188, ADR-073):
invoice_records.buyerTaxIdcarries the same three-state encodingorder_records.buyerTaxIduses (NULL= not asserted,''= asserted-none, otherwise the id, read throughdecodeBuyerTaxIdColumnand never through a bareIS NOT NULL), and it is written once, at create, byfreezeBuyerTaxIdAtIssue. Three properties are load-bearing. (1) A frozen column cannot contradict the document, and a joined read can. An order’s tax number is editable and re-ingestible; a document that has already been issued is not. Deriving the list column from the order would let a later correction to the order silently restate what a transmitted fiscal document says - so the value is taken fromcmd.buyer.taxId, i.e. from what actually went onto the document. (2) The caller’sbuyerTaxIdAssertionis consulted ONLY to tell the two absences apart, never to supply an id: an assertion carrying a number the document does not carry (an operator issuing by hand without one, against an order that has one) reads as not asserted rather than putting a number on a list row whose document has none. The rule is therefore that the projection never claims MORE than the document, only ever less. (3) The column lands nullable with no default and no backfill - every record issued before it existed reads as not asserted, which is the honest answer, because nothing in those rows records what the document carried. It is deliberately a second column rather than a widening of the pre-existinghasBuyerTaxIdflag (#1202): that flag is a presence test the list filter reads, and a value and a flag answer different questions. - Cross-capability auto-issue gate (#2156, ADR-041 decisions 3a/4/7):
AutoIssueTriggerServiceno longer resolves invoice-only. It lists ACTIVE connections with EITHERInvoicingorFiscalizationenabled, reduces each to aSalesDocumentRoutingCandidateviareadSalesDocumentRouting(connection.config), and resolves EXACTLY ONE(documentKind, connectionId)pair via thesales-documentscontext’s pureresolveSalesDocumentRouting(#2155) — invoice XOR fiscal receipt, never both, across a SINGLE cross-kind candidate pool (an invoice candidate and a fiscal-receipt candidate compete for the same “primary” slot, per decision 3a). The pre-#2156 invoicing-only primary selection (selectPrimaryInvoicingConnection, deleted in #2293) now lives inside the resolver; the gate only calls it. Before dispatch, the resolved connection’s actual support for the resolved kind is checked: for'invoice', via the adapter’s ownInvoicingPort.getSupportedDocumentTypes()(decision 7’s deeper, adapter-level check);'fiscal-receipt'has no such discovery method onFiscalizationPortyet, so the resolver’s structural check (capability enabled) is the whole validation story for that kind today. Dispatch enqueuesinvoicing.issue(unchanged) for'invoice'and a newfiscalization.registerjob (#2156) for'fiscal-receipt', idempotency-keyedfiscal:{connectionId}:{orderId}— the same key format the fiscalization HTTP controller already uses for a manual registration. The per-connectionconfig.invoicing.triggerModelkey is read for BOTH kinds (predates the two-kind split; a kind-scoped trigger-model key is a deferred, separate config-shape decision). A connection only becomes a candidate once its operator setsconfig.salesDocument.documentKind— an existingInvoicing-capable connection that has not been migrated to this key is not silently defaulted to'invoice', so an install upgrading past #2156 must set it explicitly or auto-issue simply does not fire for that connection (no error, no candidate). Persistence is wired for every non-issuing exit (unresolved-routing,unsupported-document-kind-on-connection,trigger-model-manual/batched) via the sameSalesDocumentBlockOutcomereporting seam #2100 shipped below — the gate’s cross-kind exits reached the mechanism as soon as #2100 was backported onto this lineage, so no separate wiring step was needed. The one detail carried over verbatim rather than generalized: the persisteddetailstring for'ambiguous-connection-no-primary'still reads “invoicing connections” (not “sales-document connections”), matching the pre-#2156 wording operators already see. - Not fiscalization. Registering a sale on a certified fiscal device or register is a different act with a different issuer, device dependency, legal basis and retry semantics; it is a separate capability, not an
InvoicingPortdocument type - see § 16 below. - Tax rates are an input, never a computation (#2009 / #2246, ADR-063). A line’s
taxRatearrives from the ProductMaster together with the product; OpenLinker never computes, infers or defaults it, a tax engine belongs inside the master or the sales channel rather than being a port here, and a missing rate holds the document instead of falling back to a silent value. The principle is stated once, in the VAT-rate annex of ADR-026; ADR-063 owns the mechanism and ADR-014’s per-line tax-rate rejection is reversed by the amendment ADR-063 adopts (ADR-014 isAcceptedas of that change). It is behaviour behind a switch, not yet behaviour by default - the rule is implemented end to end, andOL_TAX_RATE_STRICT_ENABLED(off by default) decides whether the refusals apply. With it off each provider adapter still substitutes its own default (inFakt 23%, Subiekt 23%, KSeF a per-connection'23') and both the auto-issue gate and theInvoiceServicewrite-path guard pass, which is what keeps the deploy a diagnosis rather than an outage on a catalogue whose coverage is zero; #2053 makes those fallbacks visible, and #2257’s removal is what the switch turns on. Pre-rollout orders are exempt even with the switch on -order_records.taxRateEra = 'pre-rollout'is read by both gates, so enabling enforcement cannot strand a back catalogue whose lines never had a rate collected for them. The switch resolution is one helper insales-documents(isTaxRateEnforced, which answers “did this deployment opt in” AND “is this order pre-rollout” together) precisely so no enforcement point can check one half and not the other; every era-aware gate (auto-issue invoice, auto-issue receipt, the invoice write path, the fiscal-registration write path, and the Subiekt line mapper) reads it throughisTaxRateEnforced, and only a channel publish - which has no order to hold an era - reads the switch alone. Four properties of the rule are load-bearing. The chain is shop-then-channel - a fix in the master serves every channel, and “the shop does not know” covers an absent product and an empty field identically.0is an answer, not a gap (export, intra-EU, exempt goods; PrestaShop already distinguishesid_tax_rules_group = 0from unknown), and not yet checked is a third read state that produces a sync suggestion rather than a block - without it, day one claims the whole catalogue is incomplete. The value is a percent-as-string code from the vocabularyInvoiceLine.taxRatealready carries (23,8,5,0,zw,np,oo), becauserateFractiondoesparseFloat/100and a number cannot express an exemption; a country code rides along as provenance only and is never compared with the buyer’s country. Rounding belongs to the adapter - core passes gross plus a rate code, computes no net and rounds nothing, which makes the observed FA(3) 162.00-vs-161.79 discrepancy an adapter bug; a stored per-line net is a copy taken back from the issued document. The contract work is #2054; the epic is #2245. - Which document a given order gets - routing policy (#2051, ADR-041; Proposed; decision 11’s visibility contract has shipped (#2100), the router itself has no code yet):
documentTypeis command data the caller supplies (ADR-026 §Decision 3), so selecting it is a policy question that lives outside both document contexts - in asales-documentspolicy module that is handed the order (never reading it back, so no dependency cycle withordersis created) and resolves at most one originating(documentKind, connectionId)pair. Invoice or fiscal receipt, never both for the same order: that exclusivity is a contract-level invariant, enforced at the write path againstpending/issued/failed-but-not-rejectedrecords on any connection, with corrections excluded as linked follow-ups. TheAutoIssueTriggerServicefan-out that breaches it today is #2047. ADR-041 also records the gating conditions, periodic aggregation as a non-issuing outcome, and the self-routing-destination bypass; the rule engine and the localised legal matrix are deferred by the ADR itself, with operator-configured per-connection choice as the first slice. The context and its edges are recorded at § Core Bounded Contexts 17 and in the § Cross-context dependencies in core map (#2100). - One originating document per order (#2047, ADR-041 §3a/3b): an order gets at most ONE originating fiscal document across ALL connections. KSeF / inFakt / Subiekt are alternative routes for the same document to reach the authority, not complementary steps, so issuing on each produced two real fiscal documents for one sale. Enforced in two layers. Selection:
AutoIssueTriggerServiceresolves EXACTLY ONE connection via the pureresolveSalesDocumentRoutingoverconfig.invoicing.isPrimary(read byreadSalesDocumentRouting; #2047 first introduced this asselectPrimaryInvoicingConnection/parseIsPrimaryInvoicing, which #2161 superseded and #2293 deleted) instead of fanning out over everyInvoicing-capable connection - and with several candidates and no unambiguous primary it issues nothing and logs, per ADR-041 §6 (silence-and-pick-one is forbidden: for a fiscal document a wrong pick is a legal event). Write-path guard:InvoiceService.issueInvoicerefuses a second document when any record on a DIFFERENT connection ispending/issuing/issued, orfailedwith afailureModeother thanrejected(InvoiceRecord.blocksIssuanceElsewhere) - only a terminalrejectedmeans the provider definitely created nothing and another connection is free to issue. Raised asOrderAlreadyInvoicedException→ HTTP 409, and as a terminalbusiness_failurein the worker’sinvoicing.issuehandler (ADR-007): the guard is a persisted-state fact, so retrying cannot change it. Corrections are excluded - a correction is a linked follow-up of anissuedoriginal, not a second originating document (ADR-041 §3b), andissueCorrectionis therefore not guarded. The guard is a read, so it is serialized by a per-order lock (SyncLockPort, keyinvoice:issue:{orderId}, TTLOL_INVOICE_ISSUE_LOCK_TTL_MS): unlocked it would be read-then-act - two concurrent attempts on different connections both observe no blocking record, and ADR-026 §Decision 4’s(connectionId, idempotencyKey)uniqueness cannot collide across connections, so both would create a row and cross the provider boundary. Keyed per ORDER rather than per (order, connection) for the same reasonshipmentDispatchLockKeyis (#1917): two operators picking different providers for one order is exactly the case a per-connection key would let through. Lock-TTL expiry is not a correctness cliff - the window that must be covered is only guard-read →create, and past that apendingrow exists that a peer’s own guard sees. A contended attempt answers from persisted state only (already-invoiced refusal, or anissuedsame-key row replayed verbatim) and otherwise raises the retryableInvoiceIssueContendedException→ 409, never reaching the adapter. NoorderIdunique index is added: pre-existing duplicates would then block creation, and the missing piece was a service-level guard. Instead the read path is connection-agnostic -GET /orders/:orderId/invoiceresolves the record’s own connection, andotherInvoicingConnectionIdssurfaces pre-existing duplicates rather than hiding them behind the single-record panel. Every block on the auto-issue path is persisted and operator-visible, never log-only (#2100, ADR-041 §54/§105 - decision 11’s first implementing slice):AutoIssueTriggerService.onOrderTransitionreturns a neutralSalesDocumentBlockOutcome(@openlinker/core/sales-documents- a dependency-free leaf concern holding decision 11’s two reason unions,SalesDocumentGateBlockReason+SalesDocumentUnresolvedReason, kept separate because they answer different questions, with'unresolved-routing'as the one bridge value) andOrderIngestionServicewrites it toorder_records.salesDocumentBlockReason/salesDocumentUnresolvedReason/salesDocumentBlockDetail. Reporting rather than persisting is load-bearing: persisting in-place would need anOrdersModuletoken insideInvoicingModule, closing the runtime DI cycle the trigger’s ONE-WAY EDGE property (F3, asserted byapps/worker/test/integration/invoicing-auto-issue-boot.int-spec.ts) exists to prevent - the caller already lives inordersand owns the write. Three reasons are reachable today:'unresolved-routing'+'ambiguous-connection-no-primary'(the #2047 ambiguity),'trigger-model-manual','trigger-model-batched';'missing-required-tax-id'and'tax-rate-conflict'ship declared but never written pending their prerequisites (#2057’s unknown-vs-resolved-zero tax rate; and for the tax-id reason a gate that acts on it - #2599 landed the buyer tax id on the order contract itself, deliberately without turning any refusal on), and the remainingunresolvedvalues await the #1908 router. The write is level-triggered, not sticky: the gate re-decides on every transition and the writer stores the answer includingnull, which is what clears a reason once the misconfiguration is fixed - plus an explicit best-effort clear on both manual-issue paths (POST /invoices,POST /invoices/bulk-issue), because fixing the config and issuing by hand fires no transition. All three columns are deliberately omitted from the repository’stoOrm(thecancelledAtsingle-writer precedent):persistOrderruns before the gate on every ingestion, so round-tripping them would null-then-reset the value and let a stale in-memory read stomp a reason a peer transition just wrote. Operator surface follows #1689’ssource_deletedtreatment - a row badge on/orders(desktop + mobile, replacing the “Issue invoice” CTA, since an order OL already refused is not one waiting for a click;trigger-model-manualkeeps the CTA because issuing by hand is its configured workflow), an undated timeline entry, and the order-detail invoice panel reading the persisted reason instead of re-deriving the ambiguity client-side. Two deliberate deviations from a literal reading of #1689’s treatment: the count ships as a non-partitioningsalesDocumentBlockedfield + filter chip, not a sixthOrderHealthbucket (deriveOrderHealthreturns exactly one bucket and its SQL twins partition the set, so a sixth value would either double-count or hide a sync failure behind an invoicing one - a blocked order is usually alsosynced), and blocked orders are not excluded from bulk issuance (POST /invoices/bulk-issuenames its connection explicitly, so every reachable reason means “auto-issue did not happen”, never “this order cannot be invoiced” - excluding them would break the primary remediation path for the state this surfacing exists to reveal). The FE mirror of the reason union is guarded byscripts/check-sales-document-reason-mirror.mjsunderpnpm check:invariants. Three properties the review round made explicit. (1) The gate is invoice-aware, and “already invoiced” is the domain’s own predicate: before reporting any block it reads the order’s own invoice projection (IInvoiceService.getLatestInvoiceForOrder- a SAME-CONTEXT read, so no module cycle and no F3 breach) and reportsnonewhen that record satisfiesInvoiceRecord.blocksIssuanceElsewhere- the same getter the #2047 write-path guard uses. Without any check the gate was not idempotent against its own effect: amanualconnection re-reports its reason on every later transition, so a block landed back on an order that had since been invoiced by hand, making the aggregate count wrong, the filtered rows badge-less, and the timeline claim “No invoice issued” under the issued invoice. Delegating rather than testingstatus !== 'failed'fixes the arm a status test gets wrong in the dangerous direction: anin-doubtfailure means OL does not know whether the provider created a document, so recording “no fiscal document was issued” against it is a claim OL cannot support - only a terminalrejectedfailure (the provider is known to have created nothing) leaves the block standing. Every FE surface mirrors that same predicate - the projection ships the derived boolean (OrderInvoiceProjectionDto.blocksIssuanceElsewhere) rather thanfailureMode, the row and timeline resolve it throughinvoiceSupersedesBlock, and the panel through the equivalentcanRetryInvoice. They have to agree: the aggregate count and the?invoicing=blockedfilter have no invoice awareness, so a block the backend keeps but the surfaces hide is a number whose rows explain nothing - the same silent decline §54 forbids, one surface down. For the same reason the row renders the invoice pill, the block badge and the “Issue invoice” CTA as independent parts (the sharedOrderInvoicingCell, used verbatim by the desktop cell and the mobile card): a three-way ternary made the badge unreachable behind any invoice record, including the rejected one the gate deliberately keeps blocked. (2)indeterminateis a third outcome, not a clear: a compose/enqueue error leaves the persisted value untouched, because three of the four errors the trigger allow-lists as deterministic throw identically forever - clearing on them erased a true reason and replaced it with nothing at all, the very silence §54 forbids. (3) The aggregate counts only attention-worthy reasons (SalesDocumentAttentionReasonValues, i.e. everything excepttrigger-model-manual), becausemanualisparseTriggerModel’s default: on a manual install every uninvoiced order carries it, and anIS NOT NULLcount put a red “Invoicing blocked 4,312” on a healthy install. The per-order badge still renders manual, neutral; it is simply never aggregated or filtered on - which also means a stored reason this build does not recognise is no longer counted, since it cannot match the IN-list. - KSeF (Polish national e-invoicing),
@openlinker/integrations-ksef, adapterKeyksef.publicapi.v2, registry capabilityInvoicing.KsefInvoicingAdapterimplementsInvoicingPort,RegulatoryTransmitter, andCorrectionIssuer(#1288): FA(3)VAT+KORdocuments are issued and cleared through the async submit→poll→UPO model (issueInvoice), andissueCorrectionis a pure delegation into the same KOR path (KSeF has no delta-only correction primitive — every correction resubmits a complete FA(3) document). See the KSeF setup guide for the full flow, current limitations, and compliance caveats. - Infakt (Polish accounting SaaS),
@openlinker/integrations-infakt, adapterKeyinfakt.accounting.v1, registry capabilityInvoicing(#1281). Infakt submits to KSeF on the seller’s behalf rather than OL building FA(3) XML itself, soInfaktInvoicingAdapterimplementsRegulatoryStatusReader(poll the KSeF-relay clearance status Infakt reports) rather thanRegulatoryTransmitter. It also implementsCorrectionIssuer,RegulatoryDocumentReader,RegulatoryResubmitter(re-trigger transmission of an already-issued document — the operator “resend” action for arejectedclearance),BankAccountsReader/BankAccountDefaultSetter, plus the accounting-specificPaymentStatusReader/PaymentMarkerandInvoiceEmailSendersub-capabilities. Document creation is asynchronous on inFakt’s side:issueInvoice,issueCorrectionandmarkPaidgo through inFakt’sasync/*.jsontask endpoints, which return a task reference (processing_code: 100) rather than the document — so those adapter methods POST the task and then block on a bounded status poll (async/{resource}/status/{ref}.json) until it resolves, before hydrating the created document. That is why an inFakt adapter method can block for seconds; a poll timeout surfaces asfailureMode: 'in-doubt'(the provider may still finish creating the document) rather than as a rejection (#1763). - Subiekt GT (via the Sfera GT bridge),
@openlinker/integrations-subiekt, adapterKeysubiekt.gt.v1, platformTypesubiekt-gt— descended from the firstInvoicingPortadapter (#753) and now carrying five capabilities (Invoicing,ProductMaster,InventoryMaster,OrderSource,OrderProcessorManager). ImplementsRegulatoryStatusReader,CorrectionIssuer,BankAccountsReaderandBankAccountDefaultSetter. Reached through classic COM automation (ProgIDInsERT.GT), NOT the .NET Sfera SDK.- The ZK’s idempotency key is OpenLinker’s own order id, not the source’s order number (#3365). The bridge serializes its check-then-create on
orderRefand finds an already-created ZK by it - an exact match ondok_NrPelnyOrygwith no scoping - so the key must be globally unique. A source order number is unique within ONE shop: those are per-shop sequential on PrestaShop, WooCommerce and Subiekt itself, and only Allegro is immune (its number is a checkout-form UUID). Two shops’ order1001reaching one Subiekt therefore collided - the second found the first’s ZK, received it back, and OpenLinker recorded the sync as SUCCESSFUL while that sale was never written.OrderCreate.internalOrderId(optional on the type, so the widening is source-compatible; populated byOrderSyncServicefromorder.id) is the key; the source’s own number moves touwagi, which Subiekt shows on the document. Three consequences are load-bearing. (1) Neither key present is REFUSED, never created (SubiektOrderKeyMissingException, terminal in the retry classifier) - a missing document is recoverable by an operator, two sales orders for one sale are two real documents in somebody’s books. Erli’s order source sets noorderNumberanywhere in its package, so the previous?? ''fallback sent an EMPTY key, which the bridge’s own comment records as running unlocked and undeduped. (2)legacyOrderRefcarries what a pre-upgrade OpenLinker would have sent, because an order whose create was mid-retry across the deploy was written under the old key and would otherwise be duplicated by the upgrade itself; the bridge probes it only after the real key misses and accepts a hit only when the gross totals agree, since that value is the colliding one. Both halves are removable one release after every deployment has upgraded. Deploy the bridge first - an OpenLinker sending the field to a bridge that does not read it is harmless (unmapped members are skipped) but the probe then does not run. (3) It also REPAIRS the bridge’sFindZkIdByOrderReffallback, which searchesdok_NrPelnyOrygfor exactly this id and, while the column held the marketplace number, could never match. - The client timeout must exceed the bridge’s own budget, and the order-create lock must exceed both (#3365).
SUBIEKT_BRIDGE_TIMEOUT_MSis 150 s against Sfera budgets of 60/90/120 s, because the bridge states that its COM-side wait is NOT tied to the HTTP request’s cancellation - so at the previous 30 s every create slower than half a minute surfaced to OpenLinker as a transport failure while SUCCEEDING in Subiekt, and the retry that followed had an empty key to dedupe on. A client timeout is only meaningful if it means the far end stopped too. That in turn invertedorder-create-lock.ts’s stated precondition (“must comfortably exceed the worst-casecreateOrderduration”), soDEFAULT_ORDER_CREATE_LOCK_TTL_MSis 180 s: the slowest destination in the tree sets that number and Subiekt currently is it. The relationship is stated from both ends, because a value only correct relative to a constant in another package needs to be readable from either. The accepted cost is named rather than implied:marketplace.order.syncruns on therealtimelane at{total: 4, perScope: 2}, so two concurrent slow creates hold that connection’s ENTIRE realtime allowance for two and a half minutes, and #2613’s penalty-free deferral cannot help because a client-side timeout is neither a 429 nor a 503. - Whether the goods left the warehouse is recorded and operator-visible (#3365).
invoice_records.warehouseReleaseOutcome/warehouseReleaseNumbercarry what the bridge answers about the WZ - the third document of the kontrahent/faktura-or-paragon/wydanie promise - and reach both the live invoice record and the order-detail projection. FOUR states, and onlynot-releasedis a claim:releasedis the ordinary success and carries its number,not-applicablemeans there was nothing to release, andnullmeans the provider has no warehouse and was never asked (inFakt, KSeF and eparagony never report). Every consumer therefore reads the OUTCOME and never the number’s nullability, which is legitimately absent on three of the four -InvoiceRecord.warehouseReleaseMissingis the pure derivation, and the FE badge resolves through it so a service-only document and an unasked provider are not accused identically.
- The ZK’s idempotency key is OpenLinker’s own order id, not the source’s order number (#3365). The bridge serializes its check-then-create on
- Subiekt nexo (via the Sfera bridge),
@openlinker/integrations-subiekt-nexo, adapterKeysubiekt.nexo.v1, platformTypesubiekt-nexo, registry capabilityInvoicing— a SEPARATE InsERT product from Subiekt GT, reached through a different bridge built on the .NETInsERT.Moria.SferaSDK. Invoicing only; it implements the sameRegulatoryStatusReader,CorrectionIssuer,BankAccountsReaderandBankAccountDefaultSettersub-capabilities. The two are never joined: different products, different bridges, four of the nine routes the GT adapter calls do not exist on the nexo bridge at all, so nothing about one may be read as a claim about the other. - eparagony.pl (#3192),
@openlinker/integrations-eparagony, adapterKeyeparagony.documents.v3, registry capabilityInvoicing— the SAME connection and the same adapter key that carriesFiscalization(§ 16).EparagonyInvoicingAdapterimplementsInvoicingPort+RegulatoryStatusReader+CorrectionIssuer(#3193): the vendor relays to the national e-invoicing hub on the seller’s behalf, so the adapter reads clearance and never submits, the Infakt split rather than KSeF’s;CorrectionIssuer.issueCorrectionreaches the vendor’s siblingeCorrectiveInvoicedocument. Three things follow from one connection carrying two document kinds. The two adapters share ONEEparagonyHttpClient— the client owns the OAuth token cache and the vendor rate-limits/auth/tokenper IP, so a second client would mean a second cold cache and a second token request; the factory therefore returns a bag (createAdapters, the Allegro/PrestaShop precedent) rather than one adapter per call. That is a within-resolution property only:getCapabilityAdapterbuilds a fresh bag on every call and memoises nothing, so two resolutions for one connection still fetch two tokens — pre-existing behaviour, not something the bag fixes.CorrectionIssueris advertised together with the adapter code that delivers it (the #980 rule), since the vendor’seCorrectiveInvoicedocument is now composed rather than refused pre-call. The invoice lane is opt-in on every path into the product —enabledCapabilitiesis stamped at create and never retro-filled (#2085), so no existing receipts connection gains it, and the guided wizard sendsenabledCapabilities: ['Fiscalization']explicitly rather than lettingConnectionService.createdefault to the whole manifest, which would otherwise mint connections claiming an invoice lane whose seller fields (merchantTIN,merchantName,merchantAddress) that wizard never collects. All four invoice-lane config keys are OPTIONAL at the shape validator, since every connection created before #3192 is receipts-only and the whole config is re-validated on every save; what the invoice lane actually needs is enforced bycomposeInvoiceDocumentrefusing pre-call with anEparagonyConfigException(failureMode: 'rejected') before anything crosses the boundary. A clearedmerchantAddressis an explicitnullunder the repo’s own cleared-knob convention (#2610), so both the validator and the mapper readnullas absent — the two halves must agree, because a mapper that dereferenced it would throw a rawTypeErrorthat core cannot classify and defaults toin-doubt, blocking that order from every sales document on every connection (#3274).
15. Analytics Trust
Section titled “15. Analytics Trust”- Responsibility: A read-only data-trust signal for the analytics UI — per
OrderSource-capable connection, whether its ingestion pipe is live and whether its order data is recent, so an operator reading a revenue/order chart can tell “sales dropped” apart from “the poll died” (#1982). No entities, no persistence, no invariants of its own — a composition of three existing seams (IIntegrationsService,ISyncJobsService,IOrderRecordService) plus two pure domain functions. - Location:
libs/core/src/analytics-trust/. - Distinct from
libs/core/src/analytics/(PostHog settings) — adjacent names, unrelated subject matter. AnalyticsTrustServiceenumeratesOrderSource-capable connections vialistCapabilityAdapters({ capability: 'OrderSource', lazy: true, includeAllStatuses: true })—includeAllStatuses(opt-in, default off) is required here: the single most common real ingestion death is a token flipping a connection toneeds_reauth, and the defaultactive-only filter would silently drop exactly the connections this read exists to warn about. A connection whose ownstatusisn’t'active'is always classified'disconnected', overriding whatever its poll history would otherwise say.- Poll liveness vs. data recency are reported as two independent facts, not one.
lastPollAt(last succeededmarketplace.orders.polljob) is a pipe-liveness signal, thresholded against a staleness window;lastOrderIngestedAt(last succeededmarketplace.order.syncjob, same connection) is the actual order-data-recency signal and is deliberately never thresholded — a low-volume connection can go days without a new order and still be healthy. Both job types are always looked up regardless of whether a poll scheduler task is registered for the platform, since a poll task can be legitimately disabled on a webhook-first platform (PrestaShop, WooCommerce) while the connection keeps ingesting fine. - The staleness threshold is derived only from a currently-enabled scheduler task (
ISyncJobsService.findEnabledPollTask, mirroringSchedulerService’s ownenabledEnvVar/enabledDefaultruntime check) — never from mere task registration, since WooCommerce/Erli register their poll task unconditionally and gate only its execution. When no enabled task matches, the threshold falls back to a 30-minute floor rather than going unset, so an unknown-cadence connection can still eventually read'stalled'. ConnectionIngestionStatus(never-ingested | fresh | stalled | disconnected | unknown) —'unknown'is a distinct degraded value for a per-connection build failure (never'never-ingested', which would assert a false claim about the operator’s data for what is really an infrastructure hiccup);computeWorstStatusrolls the per-connection statuses up to oneworstStatusfor the FE banner, rankedfresh < never-ingested < stalled < disconnected < unknown.- Cross-context seam discipline: the new context does not inject
SyncJobRepositoryPortor the concreteSchedulerTaskRegistryServicedirectly — it consumes both through the existing publishedISyncJobsServiceinterface (extended withfindLastSucceededJobandfindEnabledPollTask), keeping the cross-context contract toI*Serviceper the rule in § Cross-context dependencies in core. - Real per-connection earliest-order-date coverage window (#2083):
connectionCreatedAt(when the operator configured the integration) was never a valid proxy for “how far back this connection’s data goes” — a connection can legitimately ingest orders placed before it was created (e.g. Allegro’s event journal seeded from the beginning).ConnectionIngestionTrust.earliestOrderDateis the real fact,MIN(COALESCE(placedAt, createdAt))over the connection’sorder_records, read throughIOrderRecordService.getEarliestOrderDateByConnection— neverOrderRecordRepositoryPortdirectly, mirroring thegetFailedSyncValueSummary(#1983) cross-context precedent.AnalyticsTrustService.getIngestionTrustSnapshotcalls it exactly once, batched across every enumerated connection id, before fanning out into the (pre-existing, per-connection) job-lookup loop — never inside that loop, which would reintroduce an N+1 query for this one field. A connection absent from the returned Map (zero ingested orders) reportsearliestOrderDate: null, distinct from a non-null value that merely predates theplacedAtbackfill and resolved through thecreatedAtfallback instead. - Interface:
GET /analytics/trust(apps/api/src/analytics-trust/), guarded by the globalJwtAuthGuard.
16. Fiscalization
Section titled “16. Fiscalization”See ADR-042 for the decision rationale.
- Responsibility: hand a sale to a provider that performs or brokers its fiscal registration, and surface the result on the order. Designed in ADR-042 and built in #1908 (the neutral capability plus the first adapter); surfacing the record on the order is #1909. OpenLinker never issues a fiscal receipt: issuance is reserved to a registering device whose type carries a potwierdzenie Prezesa GUM, or to a software register meeting the same requirements, and OL stays on the feeding side of that line (#1906, art. 111 ust. 6a/6b applied odpowiednio by art. 111b ust. 2 of the PL VAT act). That states what the statute says and is not legal advice — no seller-facing compliance claim should rest on it without a professional opinion.
- Location:
libs/core/src/fiscalization/- a context of its own, deliberately not an extension ofInvoicingPort(different issuer, device dependency, legal basis, retry semantics). Code identifiers and the capability value use the-izationspelling, matching the repository’s identifier convention; prose, spec and issue titles keep-isation. - Capability:
FiscalizationPort(registry capabilityFiscalization), whose base contract carries the single invariant transaction operation both published middlewares converged on - register a transaction, receive back what identifies the registration plus whatever customer-facing artefacts it produced. Delivery channel is a variable, not a constant: the result carries a possibly-empty list of artefacts, each pairing content with an adapter-declared medium (document / markup / code / link / text) and disposition hint (print / display / send / retain), and an empty list is a successful registration — a pure reporting regime returns identifiers only. Regime-specific behaviour is composed as ADR-002 sub-capabilities: aFiscalRegistrationLocatorfor confirming an indeterminate outcome, and a device/peripheral operator for fiscal-printer regimes such as Poland (#1910) — advertised in the manifest for discovery and resolved only by narrowing the dispatchedFiscalizationadapter with its guard. Fiscal corrections are explicitly deferred; a periodic journal/audit export (FiscalJournalExporter) is the named first extension point, kept off the base port because it is keyed to a period rather than a sale and is freely repeatable. - Trust anchor stays in the adapter: certified device / security module / certified software plus hash chain / remote authority endpoint are observed classes, not core types - the class is not stable even within one country (Italy runs certified software alongside hardware RT with no sunset), so nothing in the shared contract may assume a fiscal printer exists. OL sits on the caller side of the POS boundary — it is not building the middleware, it is calling one.
- Regime-specific identifiers are carried neutrally: the registration record holds a provider-assigned reference, the document reference the registered document itself bears, a flat signing identity for whatever performed or signed it, and a timestamp — plus one adapter-owned, jsonb-backed extras bag for values with no cross-regime counterpart. Core indexes none of the extras keys; a key that shows up in a second adapter is promoted to a neutral field.
- Exactly-once registration is a core-owned contract guarantee (mandatory caller-supplied idempotency key, a durable per-connection unique index, and an atomic in-flight lease), because a double fiscal registration is a legal event for the seller. A repeat resumes status-aware: only a terminal
rejectedre-crosses the boundary; an indeterminate outcome is surfaced, never blindly resent. No degraded/offline mode ships, deliberately:in-doubtis a state OL can honestly hold, whereas a degraded artefact is one only the trust anchor may mint — and OL is never the anchor here. Amounts are transmitted as they stand - this capability never recomputes a VAT rate. Where the rate itself comes from is the proposed ProductMaster rule (ADR-026 annex, pending #2058). - First adapter (#1908):
@openlinker/integrations-eparagony, adapterKeyeparagony.documents.v3, platformTypeeparagony, registry capabilitiesFiscalizationand, since #3192,Invoicing- the vendor issues a receipt and an invoice through the samePOST /documentsendpoint under the same OAuth scopes, so one connection carries both lanes over one sharedEparagonyHttpClientand an operator enables the ones they want (the invoice half is described under § 14 Invoicing). The two lanes are independently enabled:Invoicingreaching the manifest grants it to no EXISTING connection (enabledCapabilitiesis stamped at create and never retro-filled, #2085), and the guided wizard sendsenabledCapabilities: ['Fiscalization']explicitly so a NEW connection is not born claiming an invoice lane whose seller configuration the wizard never collects. eparagony.pl is a private e-receipt distribution hub sitting in front of a fiscal printer driven by the vendor’s own software, not a fiscalizer itself, soEparagonyFiscalizationAdapterimplementsFiscalizationPort+FiscalRegistrationLocatorand no device sub-capability -print/fiscalizeare booleans inside the vendor’s document payload, not device operations, so there was never a device operation for one to call. #1910 was closednot_plannedon this finding: the printer sits below the middleware boundary on every known adapter, so a device sub-capability has no shipped consumer. Three properties of that adapter are worth knowing because they are consequences of the neutral contract meeting a real provider, not local implementation detail. (1)registerTransactionblocks on a bounded status poll. The vendor’s create answers202 Acceptedbefore the device has registered anything and may still fail afterwards, so returning at202would report a completed registration that does not exist - andregisteredis TERMINAL in core. The call therefore polls the document status to a terminal value inside a wall-clock budget kept strictly under core’s supported provider round-trip ceiling, so an expired in-flight lease can never be re-claimed mid-call (same shape as the inFakt async-task poll, #1763). (2) The locator only works because the adapter mints its own document key. The vendor’s single document read is keyed by a path token and it publishes no search by order id, registration key or date range; its create accepts an OPTIONAL caller-supplied token, so the adapter derives one deterministically from(connectionId, idempotencyKey)and re-derives it to reconcile. Without that, an indeterminate call would leave nothing to look the registration up by - which is exactly the state ADR-042 decision 7 forbids resolving by a blind resend. It also turns the vendor’s “document already exists” rejection into a resolvable outcome rather than a falserejected. (3) A locate answers from three outcomes, and one contract mismatch is still carried honestly rather than papered over.locateByQueryreportsregistered/held/not-found(ADR-042 amendment #2502, decision 1, #2520): a document the vendor holds at a non-terminal status answersheld, which reconcile surfaces asstill-unknownand which leaves the record byte-identical.still-unknownmeans the check settled nothing, NOT that the provider is holding the sale - an answer core could not read lands on the same outcome, so it is stampeddetail: 'unreadable-answer'and neither the log line nor the published API description states a fact about a provider that no adapter supplied (#2583 review). Reconcile answers from a CLOSED four-value vocabulary -resolved/not-found/unsupported/still-unknown, of which onlyresolvedwrites - so a panel offering check with the provider can state every answer it may get (#2522); a failed CHECK is deliberately not one of them and raisesFiscalReconcileCheckFailedException-> HTTP 502, because reporting a transient network failure as the structuralunsupportedwould tell an operator to stop retrying something that is merely transient, and reporting it asnot-foundwould assert an absence the provider never asserted, instead of the pre-#2520nullthat reported normal in-provider processing to the operator as an absence. OnlyCONFIRMEDmay terminalise a record, a vendor-reportedERRORanswersnot-found- which means no registration exists for those coordinates, never that the provider holds nothing, since there it holds a document it reports as failed - and core normalises whatever a locator returns throughreadFiscalLocateAnswer, so an out-of-tree adapter still answering the pre-#2502FiscalLocateResult | nullshape keeps working. What remains carried is the vendor’s fiscalization-status webhook, deliberately not wired, becauseCanonicalInboundEvent.domainis a closed union with no fiscalization member and no fiscalization job to route to, so a registered decoder would authenticate every delivery and then dead-letter it. Regime specifics - the PL rate-letter slots, grosze, the receipt envelope - live entirely adapter-side, and the per-line rate is resolved from what OL passes through or from an operator-declared per-connection slot, never inferred (ADR-042 decision 8; see the package README for the full gap list). Whether a registration is running RIGHT NOW is readable (#2521, ADR-042 amendment #2502 decision 2). The fact previously existed only as the 409 a concurrent attempt received - the correct answer to a write and useless to a reader, since a surface cannot tell someone else is registering this right now, which is reassuring and needs no action, from an error.IFiscalRegistrationService.getInFlightRegistrationand its invoicing twinIInvoiceService.getInFlightIssuanceanswer it as a pure read over persisted state - no lock, no provider call, nothing attempted - in one shared neutral shape,SalesDocumentInFlight(@openlinker/core/sales-documents, which owns it for the same reason it owns the reason vocabularies: a per-order surface covering both kinds must not branch on which context answered). Three properties are load-bearing. It reads through the SAME predicate the write path claims against (isLeaseLive), so what an operator is shown and what a second attempt would hit cannot drift. An EXPIRED claim reads as not-in-flight, because an expired lease means the previous attempt died rather than that one is running, and saying otherwise would ask an operator to wait for work nobody is doing. Thesinceit carries is the claim-holding record’s last write and therefore a LOWER BOUND on elapsed time, never the attempt’s start - nothing persists a claim-start instant, and on the invoicing path the numbering allocation writes the record inside the live lease - so a surface may say “running for at least 38s” and never “started at”. And the lease EXPIRY is deliberately not carried: it bounds how long the claim blocks a same-key retry and says nothing about how long the provider will take, so a surface handed it would render a countdown that reads as a completion estimate OpenLinker cannot make - it hands the sale over and waits for one answer, observing no steps in between. Visibility only: the lease semantics, the exactly-once guarantee and the 409 are all unchanged. What a registration produced is projected without what it produced (#2523).FiscalArtefactSummarycarries the three facts an affordance needs - the adapter-declared form, its disposition HINT and a label, plus the MIME type where the adapter knows one - and never the payload, so a per-order view can decide there is something to offer without carrying a customer-facing document into a list read; a caller that needs the bytes reads the registration record, whose DTO keeps them. An empty list stays a SUCCESSFUL registration and is kept distinct fromnull, which means the attempt never got far enough to produce anything - collapsing the two would report an unfinished attempt as a completed pure-reporting one. No delivery claim is derivable from it, and that is a property of the TYPE rather than a convention:dispositionis what the adapter suggests a caller might do, never a record that it happened, and the summary carries no timestamp, recipient, status or attempt count a surface could read as one - no shipped adapter reports whether a document reached a buyer, so nothing may say it did. Status/link surfacing on the order is #1909 and the connection UI is #1911. Which document a given order gets - invoice or receipt - is the sibling routing decision (ADR-041), not this capability’s.
17. Sales Documents
Section titled “17. Sales Documents”See ADR-041 for the decision rationale; decision 11’s visibility contract shipped as #2100, decision 1’s routing resolver as #2155, decision 9’s self-routing bypass as #2158, decision 5’s rule engine as #2170.
- Responsibility: own the neutral vocabulary and pure resolution logic that answer “which fiscal document does this order get, and why does it sometimes get none?”, shared by the invoicing gate today and by the routing decision itself. It is above both document contexts by construction: a fiscal receipt is not an invoice, so neither
invoicingnorfiscalizationcan own the question. - Location:
libs/core/src/sales-documents/. No longer types-only (#2170) — the rule engine’s write-path conflict guard genuinely needs a database, so the concern now also carries a NestJS module (SalesDocumentsModule), an application service (SalesDocumentRulesService), three repositories + ORM entities (sales_document_rules/sales_document_country_defaults/sales_document_thresholds), and — consequently — a normalsales-documents.tokens.ts(the #2100-era exception toengineering-standards.md § Symbol DI Token Re-export Conventionrule 1 ended here, per that rule’s own text). - Two rules that could match one order are refused BEFORE the save (#3190): the engine has no priorities, so when two rules match an order it resolves
unresolved/conflicting-rules-equal-priorityand the order is HELD (ADR-041 - never silently pick one, because for a fiscal document a wrong pick is a legal event). That is correct and it is also invisible: the operator meets it days later as a hanging order.detectSalesDocumentRuleOverlap(domain/domain-services/, pure, besideevaluateSalesDocumentRules) answers the question at the one moment somebody can still act on it, and the composer gates its save on the answer. Four properties are load-bearing. (1) Three outcomes, never two - a pair provably CAN both match, provably CANNOT, or this build could not decide; the third is reported rather than folded into either neighbour, because silence reads as no conflict and that is the false reassurance the check exists to remove. Of the two undecided reasons onlymulti-currency-ruleis reachable through the shipped wiring today (SalesDocumentRuleRepository.toDomainfilters a malformed persisted condition before the detector sees the row, and the DTO answers 400 for a malformed draft), sounreadable-conditionis this function’s contract with a future caller that hands it unfiltered rows rather than a state an operator meets - and a malformed persisted condition silently WIDENS its rule, consistently in the detector and the evaluator, which read the same filtered array. (2) Decidable only because the vocabulary is closed and the composer is AND-only: every condition is a bound on one field and a rule is their conjunction, so two rules intersect iff every field’s bounds intersect. An OR tree would make exactly the interesting cases undecidable, which is whySalesDocumentConditioncarries no disjunction and must not grow one without revisiting this function. (3) Different currencies are a PROOF of non-overlap, not an unknown - an inline amount is compared and never converted (#3189), so two rules priced differently cannot describe one order, and that is stated positively rather than left as an absent warning. (4) The COMPOSER refuses the save; the API stays advisory, and the asymmetry is deliberate rather than an accident of client.POST /sales-documents/rules/overlap-checkpersists nothing and refuses nothing, andcreateRulestill refuses only an identicalconditionsHash- so an overlapping-but-not-identical pair saves cleanly over HTTP while the browser blocks it. That inverts the repo’s usual rule (#2610: “the form is not the only way in… curl and MCP bypass the browser entirely”), and the reason it is nonetheless right here is that the two cases differ in what the unguarded write PRODUCES: a margin of 100% silently publishes an unchanged price, whereas an overlapping pair produces a HELD order - loud, safe, and already the behaviour ADR-041 chose on purpose. So the gate goes where there is an operator to tell, and there is no legitimate reason to proceed past it (a pair that always holds its orders serves nobody), which is why the composer offers no override. A server-side gate with an explicitacknowledgeOverlapescape hatch - theacknowledgeAboveRecommendedshapeoperational-settingsuses - is a defensible follow-up and is deliberately not shipped here; what must not happen is the gate arriving without the hatch, since that would make the advisory endpoint a refusal and strand the starter-template adopt path behind it. And a withheld answer is never read as a clean one: the composer’s save is inert while the check is settling, in flight, or was never asked because the draft is not yet one the server would accept - the first version derived its gate from the query’sdataalone, so one keystroke reset the verdict toundefined, emptied every array and re-enabled the save with no banner. Bounds are reduced per field as a conjunction (severalgtes narrow to the largest), so a last-one-wins reduction would wrongly report a collision;gteis an inclusive lower bound andltan exclusive upper one, which makes the shipped PL template’s own< 450/>= 450pair provably disjoint rather than a self-collision. - A rule’s amount lives ON the condition (#3189, reversing ADR-041 decision 5’s
thresholdRefindirection):orderTotalGrosscarried athresholdRefnaming a row insales_document_thresholds, so the amount an operator reads and the amount a rule compares against were two different things joined by a slug the operator never chose and could not author. The condition now carriesamount+currencydirectly. Five properties are load-bearing. (1)amountis a DECIMAL STRING, never a JSON number — conditions round-trip throughjsonb, and a number loses precision there and re-serialises differently, which would changeconditionsHashand therefore the unique index backing the duplicate guard. Routing compares withNumber()over a valueisDecimalAmountStringhas already made safe -order.totalGrossis itself a JS number, so no comparison against it can be more exact than that operand.compareDecimalAmountStrings(BigInt, neverparseFloat) exists for comparison between two AUTHORED amounts, where both sides are decimal strings and the exactness is real; its consumer is the #3190 overlap detector. (2) Currency is COMPARED, never converted — an order in another currency simply does not match, which is #2161’s rule kept verbatim: a second currency is a second rule, and OL will not decide a fiscal document on a rate. (3) The model and its migration ship in ONE release.isSalesDocumentConditionreturnsfalseon a shape it does not recognise and every caller reads that as this rule never matches — silently — so shipping the narrowed type without rewriting the persisted rows would have killed every threshold rule in the field with no error anywhere. The migration resolves eachthresholdRefagainst the version effective on the migration date, rewrites the condition inline, and recomputesconditionsHashwith a canonicalisation COPIED into the migration rather than imported — a migration must keep meaning what it meant when it ran, and an import would silently change its behaviour the next time the canonicaliser is edited. An unresolvable ref THROWS and rolls the migration back rather than dropping the condition, anddown()throws, because the pre-migrationthresholdRefis not recoverable from the inlined amount once a threshold row has since been versioned. (4)sales_document_thresholdsis retained but is no longer load-bearing — nothing in the evaluation path reads it, and dropping the table is a separate decision. (5) The Poland starter template’sno-tax-idrule is DELETED in the same change, because it could never match:buyerHasTaxIdreadsfalseonly for a positively asserted has none, andreadSourceBuyerTaxIdstructurally cannot produce that state — so the flagship template shipped a rule labelled Customer has no tax ID → Receipt that was dead on every install. Widening it with anotPresentoperator spanning unknown and asserted-none is explicitly NOT done here: #2599 separated those two states deliberately, and collapsing them is an ADR-063/ADR-073 amendment rather than a one-liner. - Key types:
SalesDocumentKind(CoreSalesDocumentKind | string, i.e. open-world per ADR-041 decision 10, with'invoice'and'fiscal-receipt'the two kinds core recognizes structurally; a regime may declare a third, but a document PROPERTY such as “receipt carries the buyer’s tax ID” still belongs on thefiscal-receiptoutcome rather than becoming a kind of its own) andSalesDocumentDecision(#2155, the resolver’s output — a(documentKind, connectionId)pair orunresolved).SalesDocumentUnresolvedReason(routing could not decide) andSalesDocumentGateBlockReason(routing decided, or explicitly did not, and issuance is still not allowed) are kept as two unions because they answer different questions, with'unresolved-routing'as the one bridge value carrying the routing reason alongside it (ADR-041 §107). PlusSalesDocumentBlock, theSalesDocumentBlockOutcomethe gate reports (none/blocked/indeterminate),SalesDocumentAttentionReasonValues(the aggregate-worthy subset), and the twois*coercion guards. - Two pure resolvers, both live, rule-engine-first (#2173).
evaluateSalesDocumentRules(#2170, the country-agnostic 4-tier fallback evaluator — rule match → country default →★Rest of world →unresolved/no-configuration-for-country) is now consulted byAutoIssueTriggerServiceBEFOREresolveSalesDocumentRouting(#2155, theoperator-configuredsingle-primary model), viaISalesDocumentRulesService.resolveRouting(SALES_DOCUMENT_RULES_SERVICE_TOKEN,@openlinker/core/sales-documents—InvoicingModulenow importsSalesDocumentsModule). A small pure mapper (toSalesDocumentOrderFacts,libs/core/src/invoicing/application/mappers/) builds the engine’sSalesDocumentOrderFactsinput from the order’s DELIVERY (shipping) address country +totals, withbuyerHasTaxIdread from the order’s own buyer tax id since #2599 (never defaulted tofalse- “unknown” must not collapse into “known to have no tax id”, which is whySalesDocumentOrderFacts.buyerHasTaxIdwas widened frombooleantoboolean | undefined). Fallback precedence: any rule-engine decision OTHER thanunresolved/'no-configuration-for-country'(aroute, anaggregate, or a differentunresolvedreason) is used as-is and flows into the SAME dispatch/reporting pipeline as before — a non-no-configuration-for-countryunresolved reason therefore surfaces via the existingSalesDocumentBlockOutcomeseam, never a silent fallback. Only'no-configuration-for-country'(or an order with no delivery-address country at all) falls through toresolveSalesDocumentRouting, which keeps an untouched install byte-identical to its pre-#2170 behaviour. The flagship PolandbuyerHasTaxIdcondition can match a real order as of #2599, which carries the buyer tax id on the order contract in three states - absent (the source asserted nothing),null(the source asserted the buyer has none), or the id verbatim. Four properties are load-bearing. The three states never collapse:buyerHasTaxIdisundefinedfor absent andfalseonly for a positive “has none”, soevaluateSalesDocumentRules’s existing handling of an absent fact (anundefinedcompares unequal to bothtrueandfalse) still means “this order was not decided on a tax id”. The value is carried verbatim - never validated, normalised or format-checked, because ADR-026 keeps national specifics in the provider adapter and aNIPrule inlibs/coreis exactly what that forbids. It is PII-gated likecustomerEmail: for a sole trader the tax id identifies a natural person,sanitizeAddressalready drops it from the snapshot, and a hash-only deployment stores no scalar either, which reads back as “not asserted”. And only the fact is wired, not a refusal -order_records.buyerTaxIdis denormalized off the snapshot’s billing address (NULL= not asserted,''= asserted-none, otherwise the id - so a bareIS NOT NULLreads the middle state wrong; usedecodeBuyerTaxIdColumn), but no gate acts on it and'missing-required-tax-id'is still never written. All four order-source adapters supply it as of #2822 (PrestaShop shipped first, under #2599 itself). None asserts it unconditionally, and every adapter routes the raw value through the sharedreadSourceBuyerTaxIdcoercer, so a blank or absent source field is always unknown rather than a false asserted-none - the middle state stays reserved for an adapter that positively declares “no tax id” rather than merely omitting the field, which none of the four does yet. PrestaShop readsps_address.vat_numberon both the billing and shipping address - optional and blank on essentially every consumer order, so a B2C order still reads as unknown. Allegro and Erli read it only from the buyer’s VAT-invoice request at checkout (Allegro’sinvoice.address.company+ itsids[]/taxIdfields; Erli’suser.invoiceAddress.nip, read only on the invoice address) - a private, non-invoice checkout carries neither and the order reads as unknown. WooCommerce reads it only when the store runs a VAT-number plugin that writes one of a short allowlisted set ofmeta_datakeys (WOOCOMMERCE_VAT_META_KEY_ALLOWLIST- WC core carries no native tax field at all); a store on an unlisted plugin reads as unknown, never a false asserted-none. The fact is now SENT as well as routed on, and it travels UNTAGGED (#3187 receipt half, #3224 invoice half, ADR-073 decision 1).order_records.buyerTaxIdstores a bare number, soTaxIdentifier.schemeis OPTIONAL and core hands the value over with no tag - minting one would makelibs/corename a country’s identifier system, which ADR-026 forbids and whose per-connection-default variant ADR-073 rejects by name. An adapter needing a tag supplies it, exactly as the KSeF adapter already resolves the SELLER’s own tag from its connection config. Three properties are load-bearing. An absentschememeans “untagged, decide for your own market”, never “not a tax id” - the two single-slot Polish adapters (inFakt’s one client tax field, Subiekt’s onenip) read it domestically, and reading it as absent instead would silently drop the buyer’s tax number from every auto-issued document AND, for Subiekt, downgrade the document itself from a faktura to a paragon, since NIP presence is that adapter’s document-type trigger. The KSeF FA(3) mapper is the one that cannot simply accept it, because it must place one of three mutually exclusive elements: a ten-digit value resolves domestically, and anything else carries a two-letter country prefix that<KodUE>+<NrVatUE>and<KodKraju>+<NrID>share, so the tag is the ONLY discriminator - an untagged non-domestic value is REFUSED naming the remedy rather than filed under a guessed element of a document bound for a tax authority. And a value the provider then rejects surfaces verbatim (ADR-073 decision 5); no adapter pre-judges which identifiers its provider accepts. The three-state column collapses to two at composition time - only a real number is something to send - while staying three-state for ROUTING, which is what decides the document in the first place. The auto-issue path is covered both ways by regression tests, because it decides unattended what a paid order gets. - Self-routing bypass (#2158):
SelfRoutingDocumentKindis a capability guard for a destination that fiscalizes at the point of sale and needs no separate OL-issued document — declared, with no adapter implementing it yet. - Also here, and for the same reason: the per-line tax-rate enforcement switch (#2245 review).
isTaxRateEnforced/isPreRolloutOrder/TaxRateEraValuesintax-rate-enforcement.types.ts- both document contexts and both channel adapters need one answer to “do ADR-063’s refusals apply here”, and a fiscal receipt is not an invoice, so neitherinvoicingnorfiscalizationcould own it for the other. Pure coercion overprocess.env, importing nothing, which is what keeps the zero-outbound-edge property below intact. - Also here, and for the same reason:
splitShippingAcrossRates(#2248 / #2252, ADR-063 § 5). A mixed-rate basket has to state which rate the shipping was charged at, and BOTH document contexts need that answer - a fiscal receipt is not an invoice, so neither could own it for the other. It is pure division, never tax computation: core groups an amount it was given and cuts it into parts that sum back to it exactly (remainder on the largest part), while the rounding rule for a rate stays in the provider adapter. A single line with no rate makes the mix unknowable, so the split refuses and the whole document waits. It takes the currency’s minor-unit exponent rather than assuming two decimals (#2260 review), because parts that sum exactly is the only property it promises and that is false at two decimals for JPY and for KWD;minorUnitExponentForis the single table, which the fiscalization mapper’s total-reconciliation epsilon now reads too instead of keeping its own copy. The browser bundle cannot depend on@openlinker/core(#591), so the function exists a second time as a frontend mirror the invoice panel previews with - held identical byscripts/check-shipping-tax-split-mirror.mjsunderpnpm check:invariants, after the two halves drifted once and the panel showed an operator shipping parts that did not add up to what the buyer paid. - The operator-facing read surface (#2501 / #2503, ADR-065 and ADR-070; proposed, no code yet): three surfaces answer the same question about one order -
/settings/sales-documents, the/ordersrow and the order-detail panel - and they read one neutral per-order projection rather than each composing the invoice and fiscal records themselves. The projection carries the resolvedSalesDocumentKind, the status on the axis belonging to that kind, the identity fields a surface renders, the persisted block/unresolved reasons verbatim, and the other connections holding a record for the order. Two properties are load-bearing rather than stylistic. The axes are per kind, and the other one is unrepresentable, not merely unset: an invoice carriesInvoiceStatusandRegulatoryStatus, a fiscal receipt carriesFiscalRegistrationStatusalone and terminalises atregisteredwith no authority rung at all - so a flattened single enum was rejected, because it makes issued, then rejected by the authority (the stateRegulatoryResubmitterexists for) impossible to display, and puts a meaninglessclearedon every receipt. And a surface never re-derives a fact the backend persists: the reason an order has no document is read fromorder_records.salesDocumentBlockReason/salesDocumentUnresolvedReason(#2100), never inferred from the order - a design draft that inferred it printedno rule for PLover an entirely different persisted cause, which is a false statement about the operator’s own configuration. The list read is batched across order ids (thegetEarliestOrderDateByConnectionprecedent, #2083), because the alternative is two reads per row. A companion market-discovery read enumerates the delivery countries orders actually arrive from, with a count per window, so an unconfigured market is visible before an operator discovers it through missing documents - it is strictly a read, and creates no rule, default or routing of any kind. Starter templates are country-keyed data with a citable source and Poland is the only entry; a market without one is offered no recommendation rather than a generated one, and a detected-but-unconfigured market is a neutral state, since a fresh install with no routing is not broken. What the routing-first UX commits every surface to - state the decision, offer only that document, never fall back to asking - is recorded as an amendment to ADR-041, and the four fiscal-lifecycle gaps the surfaces exposed (a third locate outcome, a readable in-flight lease, a closed reconcile vocabulary, asynchronous manual registration) as an amendment to ADR-042. The UX these decisions were derived from is committed atdocs/plans/mockups/sales-document-routing.html, hash-addressable per state. Implementation is the #2513 epic; nothing in this bullet ships yet. - Zero outbound edges to sibling CORE contexts, enforced — narrower than “zero outbound edges” since #2170. Both
invoicingandordersvalue-import it — from a domain entity, a repository port, an application interface, an application service and an infrastructure repository.libs/core/src/__tests__/barrel-purity.spec.tsfails the build on any non-relative import of a@openlinker/core/<ctx>specifier under the directory (with three authorized type-only exceptions, all reaching a<ctx>/typescycle-breaker sub-barrel that re-exports no runtime value:Orderfrom@openlinker/core/orders/types(#2155), and the invoice and fiscal status vocabularies from@openlinker/core/invoicing/types/@openlinker/core/fiscalization/types(#2515, ADR-065) - a type-only import erases, so none of the three emits arequire()) —@nestjs/*/typeorm/node:*imports are unrestricted, since those are ordinary infrastructure dependencies every other context also has.
18. Currency
Section titled “18. Currency”See ADR-040 for the decision rationale.
-
Responsibility: hold the published reference rates OpenLinker reads, and the one system-level currency every order total is additionally expressed in, so that a cross-currency total is expressible at all. An order’s own
currency+totalAmountstay untouched and authoritative; the reporting figure is stamped at ingestion against the rate that applied then, because read-time conversion cannot produce a defensible financial figure and re-ingestion must not move one. The stamp is analytics-only - see the closing bullet, which is the one thing in this section that can cause legal damage if ignored. -
Key entities:
ExchangeRate(a published rate as the shared registry stores it;StoredExchangeRateonce it carries a registry id) andReportingCurrencySetting(the singleton row). Tables:exchange_rates, keyed(source, from, to, rateDate), andreporting_currency_setting, keyedid = 'singleton'- the shapeai_provider_active_settingestablishes. -
Location:
libs/core/src/currency/- a leaf context by construction: nothing under it imports a sibling@openlinker/core/*context, and nothing under it speaks HTTP. Both properties are load-bearing rather than incidental, and the coverage advisory is where they bite: reading the native currencies observed on existing orders is composed in the interfaces layer, because doing it inside the context would cost it the leaf property. -
One system-level reporting currency, resolved
settings row -> OL_REPORTING_CURRENCY -> 'EUR'. It is a property of the reporting entity, not of a channel, so deriving it per connection would derive a business choice from channel facts and make a deployment-wide total impossible by construction - the problem the stamp exists to solve. The default converts and nobody chose it, so the resolved value is always reported with the rung that produced it (ReportingCurrencySettingsView.source:setting | env | default), which is what a surface renders asEUR (default)rather than comparing against a hardcoded'EUR'client-side.Connection.config.currency(#362) keeps its existing meaning and readers - a genuine fact about what a shop prices in - and is explicitly not consulted for reporting; there is noConnection.config.fxkey and no per-connection FX configuration. Save-time validation is three layers and zero HTTP: ISO-4217 shape (400), membership ofSUPPORTED_REPORTING_CURRENCIESnarrowed by what the registered providers quote (422, the hard gate, a pure array test so an unreachable provider can never block a save), and a coverage advisory that warns and never blocks - blocking on history would let one junk currency in an old snapshot make a legitimate reporting currency permanently unsettable. -
The rate source is a code constant, not a second setting:
SOURCE_BY_REPORTING_CURRENCYinrate-source-resolution.tsmapsPLN -> nbpandEUR -> ecb. Each publisher quotes against exactly one base (NBP publishesX -> PLN, ECB publishesEUR -> X), so pairing the source with the reporting currency is what keeps every pair a direct published quote or a single documented inversion. Left as an operator choice, a PLN-reporting deployment reading ECB would pivot every order through EUR for no benefit. The pivot path stays implemented, so a third reporting currency is additive: one adapter plus one entry in the same map. -
Direction is an invariant of the stamp, not of the registry.
ExchangeRate.rateis the number oftounits per onefromunit. For a stamp,fromis always the order’s own currency andtothe reporting currency, so a stamp is alwaystotal × rateand never a division - getting it backwards produces a plausible number, throws nothing, and is wrong by the square of the rate. The registry itself is consumer-neutral: it stores published rates rather than stamps, so a future consumer with a different target simply stores its own(from, to)rows without weakening the invariant. A non-published figure records how it was obtained (derivation:direct | inverted | pivot, plus each leg’s pair, document reference and effective date) andderivationisNOT NULLeven for a direct rate, so it is never a field a consumer has to guess about.rateis a string end to end against anumeric(18,8)column -Number()-ing it intoDomain, which every other money column does, would reintroduce binary-float error into the one figure whose purpose is to be auditable. -
The registry is append-only by construction, not by convention:
ExchangeRateRepositoryPortdeclares exactlyfindByKeyandinsertIfAbsent- no update, no upsert, no delete, no id-carryingsave. A stamped order points at a registry row as evidence, so a rate editable after the fact would make every figure derived from it unverifiable; adding a mutating method is not a refactor, it changes what a stamp means. Get-or-create is insert-then-recover (unique violation -> the domainDuplicateExchangeRateError-> re-select the winner), matchingIdentifierMappingRepository.insertMapping. No database-level guard ships - see ADR-040 § Consequences for why a trigger would first fire in production. -
The stamp is written once, and the first attempt snapshots its own intent. The write is a narrow conditional UPDATE that fires only when no stamp exists (the
ShipmentRepository.claimWaybillRelayshape:IsNull()in the WHERE,affected > 0as the answer), never thepersistOrderupsert path, so re-ingestion cannot move a reported figure. Before any rate lookup, the resolved reporting currency and rule are persisted asfxIntendedCurrency/fxRule, and the retry job and the reconcile sweep read that snapshot instead of re-resolving. That snapshot is the point: without it, an order degraded to the retry path could stamp a different currency than the same order stamped inline, making provider availability a silent input to a financial figure - and the reconcile sweep, whose lag is unbounded by design, could reclassify arbitrary history after any setting change. Changing the setting therefore does not restate history; a deployment that changes its mind carries two reporting-currency eras, and restatement is filed as #2096. -
Port in core, providers in
@openlinker/integrations-fx- theAiCompletionPort/@openlinker/integrations-aisplit, and deliberately not conditioned on whether a source needs a credential today, so nothing moves packages if NBP or ECB adds a key.FxIntegrationModuleregistersNbpExchangeRateAdapterandEcbExchangeRateAdapter(plus a deterministicFakeExchangeRateAdapterfor tests) into the coreExchangeRateProviderRegistryServiceat boot, exactly as integration modules already populateAdapterRegistryService(#570/#571), and appears in bothapiPluginsandworkerPluginspurely as a module-composition seam. Providers are not capability adapters: a published reference rate is a shared read of a public source, not a per-connection capability, so there is no adapter manifest entry, nocreateCapabilityAdapter, and nogetCapabilityAdapterpath - and the connection-bound HTTP transport is structurally unusable here, since it keys its cache and its rate-limit bucket onconnection.idand a rate read has no connection. One shipped detail diverges from what the ADR anticipated:ExchangeRate.sourceRefis a source-assigned document reference only for NBP (its table number, e.g.149/A/NBP/2026). ECB assigns none - itsheader.idis a fresh UUID per request - so the ECB adapter records an OpenLinker-constructed, re-executable locator (ECB:EXR(1.0):D.PLN.EUR.SP00.A@{TIME_PERIOD}) instead, and that difference is stated rather than papered over. -
The rate-date rule is calendar-neutral, and that is a correctness requirement.
resolveRateDatederives a candidate calendar day fromplacedAt(the only shipped rule,prev-business-day, yields the preceding calendar day; “business” names the rule’s intent), and each adapter absorbs its own publication calendar from that candidate - NBP walks back over the Polish working-day calendar, ECB passes it asendPeriodwithlastNObservations=1and lets the API answer “the last publication on or before this day”. A shared Polish calendar in core would be wrong: ECB publishes on Polish-only holidays, so an order placed the day after one would be stamped with a rate one or more days stale, silently and with no error anywhere (verified live: a Polish calendar skips Corpus Christi 2026-06-04 and resolves 4.2383 where ECB’s actual last publication before Friday 2026-06-05 is 4.2368). The candidate is clamped to today in Warsaw, which is likewise load-bearing rather than defensive - a futureendPeriodmakes ECB answer a months-stale rate at HTTP 200 with no signal of any kind. A missing or unparseableplacedAtreturnsnull, the terminal signal (no stamp, no retry): the WooCommerce order source setscreatedAtbut notplacedAt, so a foreign-currency WooCommerce order is recorded unstamped by design rather than retrying forever (#2097). -
Five persisted states on
order_records, and two predicates a consumer gets wrong. The columns land with #2124 and are written by the stamp service and its jobs (#2125):State reportingCurrencyreportingTotalAmountexchangeRateIdfxRulefxStampedAtfxIntendedCurrencyNever attempted (pre-feature, or not yet reached) NULL NULL NULL NULL NULL NULL Attempted, deferred to the retry job NULL NULL NULL set NULL set Terminal (no placedAt, unsupported pair)NULL NULL NULL set set set Stamped, same currency set set NULL set set set Stamped, converted set set set set set set A consumer must read
reportingCurrency IS NULLas “unstamped” and neverexchangeRateId IS NULL, which is also NULL on the same-currency path - i.e. on the overwhelming majority of orders, every one of which would be silently discarded.fxStampedAt IS NULLis not an equivalent test either: it is also NULL on a deferred row, which is legitimately still in flight rather than unstampable. The sweep predicate isreportingCurrency IS NULL AND (fxStampedAt IS NULL OR fxStampedAt < now - terminalRetryDays): the first arm is exactly the never-attempted and deferred states, and the second is a cooldown re-admission of a terminal row (#2135 review). A terminal answer is terminal about the classification, not about the world -no-rate-sourceclears the moment a host is rewired withFxIntegrationModule, and anunsupported-pairraised by a throttled public provider clears by itself - so without the second arm a transient 429 permanently cost those orders their reported figure, silently and with no re-open path anywhere in the codebase. The cooldown defaults to 7 days (payload-overridable per scheduler descriptor), and a terminal re-answer movesfxStampedAtforward so the row is retried once per cooldown rather than on every tick.reportingCurrency IS NULLis present in both arms, which is what keeps the stamp itself immutable: a row that carries a figure is never re-entered whatever its marker says - with exactly one exception, added by #2468 and recorded as an amendment to ADR-040. An order stamped under a PREVIOUS reporting-currency era is not merely recorded in an old currency, it is invisible to every KPI:getDailyOrderAggregatessumsreportingTotalAmountonly wherereportingCurrency = :currentReportingCurrency, so a prior-era stamp reads as unconverted, the same bucket as a row that was never stamped at all - and immutability is what keeps revenue under-reporting forever.OrderRecordRepositoryPort.clearFxStampForRestatementis therefore the one writer allowed to move a stamp: it nulls the same six columns the1840000000000-reset-fx-stamp-for-mislabelled-prestashop-ordersmigration established, in one guarded statement, and is reachable only fromOrderFxRestatementServiceinside ananalytics_remediation_runsaudit row. The ledger is what makes the exception acceptable rather than a hole in the rule: what ADR-040 forbids is a financial figure that moves with no traceable cause, and a row naming who asked, when, and over how many orders is a different act. Two details are load-bearing.fxIntendedCurrencymust be cleared with the rest -resolveIntentre-pins a persisted intent and never consults the settings service, so leaving it behind re-stamps the SAME stale currency and the repair looks successful while changing nothing. And the restatement page callsIOrderFxStampService.stampdirectly, sequentially, in-process inside the already-bulk-lanedanalytics.currency.recalculatejob — NO CHILD JOB, and therefore no separate idempotency-key space to worry about (OrderFxStampService.enqueueRetry’s barefx:{orderId}is simply never reached on this path). That also means no free per-order failure isolation the way one job per order would give:stampOneguards eachstamp()call the same wayclearOneguards the clear, so a caught failure counts asfailedand the loop continues rather than aborting the page. This is deliberately NOT #2096, which remains open: that is re-expressing the whole corpus after a setting change and needs an era model, whereas this is a bounded, operator-initiated repair of a population the Data Coverage panel already reports, over a window the operator already chose. The same reasoning made 429 and 408 transient rather than terminal in both adapters (isTransientFxStatus), and made an empty provider registry a boot failure (CurrencyModule.onApplicationBootstrap->NoExchangeRateProvidersRegisteredError) rather than a condition discovered one silently-unstamped order at a time. Because the equal-currency path writesreportingTotalAmount = totals.totalwith no lookup and no I/O,reportingCurrency- notexchangeRateId- is the only discriminator that means what it says. Any analytics figure built on stamped rows must therefore be paired with an unstamped count. -
This stamp is analytics-only and must never supply a fiscal document’s rate - in particular never FA(3)
KursWaluty. Stated positively because an earlier draft of the plan asserted the opposite, and the nearest persisted rate is the one the next person implementingKursWalutywill reach for. It differs from a statutory conversion on all three axes that define a rate, so no arithmetic recovers one from the other. Date: this stamp anchors onorder.placedAt; PL VAT art. 31a ust. 1 anchors on the last business day preceding the day the tax obligation arose (art. 19a), which under ust. 8 is the payment instant when the buyer paid first - a timestamp no shipped OL source persists (Allegro reads one and discards it), so placement is not merely a different date, it is structurally never the tax point for OL’s most common order shape. Target: always PLN statutorily, whatever the deployment reports in. Derivation: a statutory rate must be a directly published table-A quote, never an inverted or pivoted cross. Invoicing therefore computes its own rate and does not consume this stamp, and the two figures for one order legitimately differ - which bites hardest where it looks safest, because on a PLN-reporting deployment both come from NBP table A and differ only by date, so the rows look interchangeable and are not. Rate ownership stays per-provider (KSeF is the only path where OL builds the document at all; inFakt and Subiekt nexo convert server-side and must not be handed a rate). FA(3) draws the same line in its own schema:KursWaluty(schemat_fa3_v1-0e.xsd:3199) is scoped to dział VI ustawy, whileKursUmowny/WalutaUmowna(:3490) is scoped “Nie dotyczy przypadków, o których mowa w dziale VI” - the only element a self-computed rate may occupy. -
The registry is readable over HTTP (#2778).
GET /currency/rates?from=&to=&date=(apps/api/src/currency/http/exchange-rates.controller.ts) is a pure registry read of the stored row, or 404 - backed byIExchangeRateLookupService.findRate, a one-methodfindByKeypassthrough deliberately kept separate fromICurrencyRateServiceso the endpoint structurally cannot reach the get-or-creategetRateForand spend the deployment’s provider budget on an HTTP caller’s behalf. Carries@Roles('admin', 'operator', 'viewer')rather than@Roles('admin')- it is the evidence behind a figure any operator can already see on/analytics, not a deployment-wide write. (It is not merelyJwtAuthGuard-gated: since #2413RolesGuarddenies an undecorated route, so every route names its audience explicitly - seeengineering-standards.md § Route authorization.) The same provenance is threaded onto the display-currency conversion result:AppliedRate {from, to, rate, rateDate, source, derivation, sourceRef}onNativeCurrencyBreakdown/OrderDateConversionResult,nullexactly when no lookup happened (an unresolved row, or a same-currency identity that never called the provider), surfaced asappliedRates[]onDisplayCurrencyConversionDto.ratestays astringend to end on both surfaces, matching thenumeric(18,8)registry column. -
A provider may declare its own publication calendar to the pre-fetch cache read (#2777, ADR-040 § Amendment (#2777)).
ExchangeRateProviderPort.resolveExpectedPublicationDay?(candidate): stringis optional (ADR-046 probe-not-trust:CurrencyRateServicecheckstypeof provider.resolveExpectedPublicationDay === 'function'rather than trusting the type), and both shipped adapters declare it - NBP from the Polish working-day calendar, ECB from a weekend-only rule that must not borrow NBP’s, since ECB publishes on Polish-only holidays. Declaring it changes only which key the pre-fetchfindByKeyREAD uses:fetchRatestill receives the raw candidate and the registry still never writes a row under a non-publication date, so a stored rate is always the source’s own published answer. The guarantee is asymmetric - a day resolved too LATE only ever costs a cache miss (falls through tofetchRateunchanged), while a day resolved too EARLY (walking back past a genuine publication day) silently hits a row written for a different, earlier day and stamps the wrong rate on a financial figure with no error anywhere - so an implementer in doubt must return the candidate unchanged. A shared port-contract suite (@openlinker/core/currency/testing) binds NBP, ECB and the fake to this contract.
19. Catalog Trust
Section titled “19. Catalog Trust”- Responsibility: A read-only, per-connection catalog-replication trust signal (#2258, the operator-facing half of ADR-048 decision 2) — which capability rung a
ProductMasterconnection’s adapter declares, whether the opt-in delta pass is actually enabled, and when the deletion-reconciliation pass (master.product.reconcile, #2242) last completed a cycle. No entities, no persistence, no invariants of its own — theanalytics-trustshape (#1982): a composition ofIIntegrationsService,ISyncCursorsServiceandISyncJobsService. - Location:
libs/core/src/catalog-trust/. Interface:GET /connections/:connectionId/catalog-trust(apps/api/src/catalog-trust/, nested route prefix — the bareconnectionsprefix is owned by the integrations module); rendered as a panel on the connection detail page’s health tab, gated on the connection’s enabledProductMastercapability. - The rung is guard-narrowed, never manifest-derived.
ModifiedProductListeris guard-only (#2220 — absent from every manifest and fromCoreCapabilityValues), so the service resolves the dispatchedProductMasteradapter and narrows withisModifiedProductLister.listCapabilityAdapters({ lazy: true, includeAllStatuses: true })answers only membership — in lazy mode the entry’sadapteris the memoized construction Promise, so narrowing it directly would silently classify every rung'full-enumeration'; the rung read goes throughgetCapabilityAdapter. A resolution failure (disabled connection, credential failure) degrades to a distinct'unknown'rather than asserting a rung the adapter did not answer for; the base'full-enumeration'rung is a declared, correct state (PrestaShop, #2221), not a degradation. - Reconcile recency is a new cursor fact. Cycle completion used to be an absence (the sweep cursor cleared to
'',completed: truelog-only). The reconcile handler now additionally stampsmaster.product-reconcile.completedAt:connection:{id}(ISO,connection_cursors, no schema change) on the completing branch — deliberately after the cursor clear (a crash between the two leaves a completed-but-unstamped cycle, the safe direction for a display fact). The sweep-key vocabulary (masterSweepCursorKey/masterSweepCompletedAtCursorKey,MasterSweepKind) moved to@openlinker/core/syncas the single source; the worker’sbounded-sweep.tsre-exports it under the original names. - Honesty rules the vocabulary:
reconcileCycleOpenmeans a cycle started and has not completed — never “running” (the failure branch retains the cursor across backoff, and the cursor survives the task being disabled); the FE presents the cron as the tick, not the cycle (a cycle spansceil(N / budget)ticks); anddeltaPassEnabled(via the newISyncJobsService.findEnabledTaskByJobType, which serves capability-scoped tasks thatfindEnabledPollTask’splatformTypefilter can never match) closes the “declared but dormant” misreading — amodified-sincerung with the pass disabled still full-enumerates in practice. The task-registry read is meaningful only in the API process (the worker has no scheduler), which the interface docblock states.
20. Fulfillment Authority
Section titled “20. Fulfillment Authority”See ADR-052 for the authority matrix and ADR-053 for the leaf posture.
- Responsibility: own the neutral vocabulary for which system decides what across the six independently assignable fulfilment authorities (ADR-052’s matrix rows A1–A6) —
availability,sourcing,fulfillment-execution,order-lifecycle,returns-disposition,refund-trigger— plus the scope, selection and outcome types each resolution speaks. Types and pure functions only; no module, service, repository, port or tokens file. - Location:
libs/core/src/fulfillment-authority/. A7 (invoicing / fiscalization) deliberately carries no member —sales-documentsalready owns that question (ADR-041), so the count is six on purpose. - A zero-outbound-edge leaf, and that is the load-bearing property — not framework-freedom. Resolution lives where the write lives (A1 in
inventory, A2/A3 infulfillment, A4 in the lifecycle projection, A5 inreturns, A6 inorders), so every one of those contexts will value-import this leaf and a single edge back from here would close a CJS module-load cycle. A singleoms-policycontext resolving everything was rejected for exactly that graph reason. Enforced per leaf bylibs/core/src/__tests__/barrel-purity.spec.ts(#2308), whose allow-set for this leaf is empty — it reaches no sibling at all today, and its first type-only import must be a deliberate one-line registration rather than a free ride on a neighbour’s carve-out. - The tokens-file exemption is a starting posture, not a vow (ADR-053;
engineering-standards.md § Symbol DI Token Re-export Convention): the leaf grows an ordinaryfulfillment-authority.tokens.tsthe day it needs a binding, exactly assales-documentsdid at #2170 — which kept its sibling-edge property intact throughout, proving the two properties are separable. - Nothing consumed it at #2304: every reason member was declared and never written, and both pure functions had no production caller. The vocabulary shipped first so the contexts that adopt it adopt one spelling. #2351 gave the leaf its first computed answer (
resolveAuthorities, the seven rows of the who-decides table) and #2352 its first persisted consumers. - The inert-state vocabulary (#2352, Wave-2 product spec §4.2/§4.3) —
AuthorityAttentionReason, the eight operator-facing states behindNeeds attention (N)and every cross-surface row badge, withAUTHORITY_ATTENTION_REASON_DESCRIPTORSas the single data table §4.3 mandates (“one table, two readers”) andAuthorityAttentionCountedReasonValuesderived from itscountedflag rather than hand-listed, theSalesDocumentAttentionReasonValuesshape. Five properties are load-bearing. (1) It is a THIRD union, not a widening, because four members (line-unfulfillable,reservation-shortfall,restock-blocked,return-unmatched) are not authority-resolution failures at all — a reservation shortfall is a stock fact. (2) The other three ARE, so they are PROJECTIONS ofFulfillmentAuthorityBlock, never parallel spellings —attentionReasonForAuthorityBlockis the primitive andattentionReasonForAuthorityQuestiona thin composition over it, so an owning context’s persisted block (ADR-053) and this read model cannot describe one situation two ways, which is precisely whatfulfillment-authority-outcome.types.tswarns against. (3) A derived state is never persisted.originrecords how each is obtained: A1-U/A2-A/A5-A are pure functions ofConnection.configrecomputed on every read, and OR-P is derived from one nullable column (ReturnRecord.isOrphan(), #2332) whose own docblock forbids a second definition — so no reason column was added toconnectionsat all, and a stored copy would be a second answer with a staleness window (andConnectionRepository.updateis a read-modify-write full-rowsave(), so it would race the operator’s own edit). Only A3-X/UF-L/RS-S/RB-L are stored. (4) A1-U is covered in its AMBIGUITY half only. §4.2 words it as “two connections claim the same stock, or the claiming system errored”; the second half is a runtime fact (getCapabilityAdapteris active-only — hence the leaf’s existingholder-connection-unresolvable), is not expressible in config, and belongs toinventory’s own enforcement resolution. Stated so a consumer cannot readavailability-unknownas “A1-U is handled”. (5) AF-X is deliberately outside the union — spec §4.2 tables nine rows; the automation-failure state is produced per FIRING, carries the underlying operation’s verbatim reason and clears on retry or an explicit “I handled this myself”, a lifecycle no entry here models. Its owner is the automation body, and the totality spec asserts an explicit eight rather than a spec row count. - Persistence: one
jsonbarray per owning row, keyed by PRODUCER (#2352) —order_records.omsAttentionandreturns.omsAttention, both with the #2100 discipline (excluded from everytoOrm, a single narrow atomic writer, coerced through a guard on read so an unrecognised value reads as absent, an explicit counted IN-list rather thanIS NOT NULL). The array is the one deliberate divergence from #2100’s scalar, and it is a correctness requirement.salesDocumentBlockReasonis safe as a scalar because ONE authority re-decides the whole question on every order transition, so itsnullis a complete statement; here the writers are three unrelated subsystems (the reservation ledger, routing, the execution handshake) and an order can genuinely carry two states at once — one line unroutable, another short. A level-triggered scalar would make each producer’s “nothing is wrong” honest about its own question and a lie about the others’, so the operator-facing count would depend on which subsystem ran last.updateOmsAttention(id, producer, outcome)therefore replaces or removes exactly one producer’s entry, in a single in-Postgres read-modify-write (a caller-side rebuild would lose a peer’s entry written between the read and the write), preserving that entry’ssinceacross a change of reason inside one episode so an operator-facing age is not reset by a refinement.indeterminateleaves the entry alone, never collapsing into a clear. No index ships, deliberately: nothing writes either column yet, and the obvious shape — a partial index over a hardcoded reason list — is exactly what silently went stale onIDX_order_records_salesDocumentBlockReasonwhen #2248 widened that union and left the index behind. The producing issue adds one against its own data. - The HTTP surface, and why it is not in the leaf (#2353) —
GET /fulfillment-authority/status,POST /fulfillment-authority/presets/previewandPUT /fulfillment-authority/presetslive inapps/api/src/fulfillment-authority/, composed by an app-layerAuthorityStatusServiceoverIConnectionService+IIntegrationsService+ the order-record port — theRateLimitStatusService/WebhookStatusServiceshape, except that those injectConnectionPortdirectly (which they may, living inside the module that provides it) while this one reads AND writes throughIConnectionService, a repository port being an intra-context contract. It cannot live in the leaf (the empty allow-set above is what keeps the graph acyclic) and did not earn a fifth trust-shaped core context for one page. Five properties are load-bearing. (1) The read enumerates EVERY connection whatever its status — A2/A4/A6 areconfig-only, so any connection may claim them, andisActiveis REPORTED rather than filtered upstream: an active-only read is theanalytics-trusttrap, and here it would hide exactly the disabled connection whose lingering claim an operator is trying to understand.supportedCapabilitiescomes fromresolveAdapterMetadata, a metadata-only lookup that constructs no adapter and resolves no credentials and therefore works on adisabledconnection wheregetAdapterthrows; a resolution failure degrades that connection to advertising nothing rather than dropping it, since aconfig-onlyclaim does not consult the list and would otherwise be silently lost. (2) The attention list has TWO sources and the API returns both — the derived half is recomputed from the ambiguous rows viaattentionReasonForAuthorityQuestion(never stored, per the rule above) and carries the offendingcandidateConnectionIds; the persisted half iscountOrdersWithOmsAttention(). Returning only one would ship an endpoint structurally incapable of showing A1-U/A2-A/A5-A, which is most of what a zero-config install has to render. Theroutinearray is always empty today and says so in its own Swagger description, because §4.3’s routine states live on the who-decides ROW as anAuthorityState/AuthoritySource/AuthorityAnswerand cannot enter the union — stated so no consumer reads the empty array as broken and invents a client-side split. (3) Preview mutates a COPY and commits nothing, which is possible only becauseresolveAuthoritiestakes every input as a plain argument; it is pinned by an integration test that re-reads the whole status response after a real preview, not by inspecting the code path. It is authorised as a READ despite being a POST — #2355’s confirm dialog must render for a read-only role that then cannot save — while the apply is@Roles('admin'). (4) The 422 guard is over the RESULT, never the delta:resolveAuthoritiesmay render and may inform and may never gate a write (#2351), so the apply resolves the configs it is about to write and refuses if any row comes backambiguous, naming both connections and writing nothing. That also makes the refusal reachable in Wave 2 without a preset that assigns — an install already carrying two claimants is refused by every preset including the no-op, which is story S1-4 exactly. (5)AUTHORITY_PRESETSis the single place a preset’s semantics live.leave-as-they-arereturns the very same config reference (card 1: “Nothing changes when you pick this”), so no connection is written and noupdatedAtbumped;openlinker-decidessetsenabled: falseon every assignable claim and preserves the assignment — the connection, itsscopes, itsisPrimary— so the change is reversible by re-enabling and a preset switch is never a silent deletion of configuration the operator cannot reconstruct;keep-other-systemisavailable: falsewith a reason CODE and is RETURNED rather than omitted, so the page renders it disabled-with-a-reason (the #2170 disabled-checkbox discipline). A6 is excluded from the mutation loop entirely rather than merely being harmless to write: rewriting the key would imply the claim had ever been honoured (ADR-056). The key is only READ, not reported — the A6 row isfixed-by-designand itsinactiveClaimantConnectionIdsis hardcoded empty, so no surface today tells an operator that their claim was ignored; the enforcement is right and the surfacing is simply not built. Writes go throughIConnectionService, neverConnectionPort— the service is where per-plugin config-shape validation,adapterKeyimmutability and the taxonomy bootstrap live. Every field on the wire is a CODE; operator copy is the frontend’s and must passcheck-ui-vocabulary, which a backend string could never enter. - The operator surface (#2354) —
/settings/who-decides(apps/web/src/features/fulfillment-authority/, reached from aSettingsPagetile) renders the three § 3.2 arrangements and the always-present seven-row § 3.3 table. Four properties are load-bearing. The tile is NOT admin-gated, unlike all five of its neighbours: #2353 authorises the status read for a read-only role precisely so it can see who decides what, and gating the tile would make that read unreachable for exactly the role the endpoint was widened for — the write control inside the page is whatuseWriteAccess('connections:write')gates, andsettings-page.test.tsxpins the ungated tile directly beside the opposite expectation for the Mailer tile so the deviation is legible at the point of temptation. Every rendering decision readsstateandsource, never the question — A6’s lock and A7’s link-out are core’s statements (deriveAuthorityState,AuthoritySource), so aquestion === 'refund-trigger'test in the browser would be a second copy of a rule that lives in the leaf; thestatearm is an EXHAUSTIVE switch with aneverdefault rather than a fall-through, because anotherwise -> Chosenarm is total only by an invariantapps/webcan neither import (#591) nor observe, and would renderChosenon a row where nothing is decided the day core reaches'unavailable'another way. An ambiguous row’s why-line is REPLACED by the matching § 4.2 body (spec § 3.3) — found by matching the row’squestionagainst the attention items the same response already carries, so core’s kind-to-reason map is not mirrored a second time in the browser; an unrecognised reason degrades to #2357’s shared unknown copy rather than a blank cell. And an apply reports three distinguishable outcomes: saved, REFUSED (422 — the result would be ambiguous and nothing was written, with both connections named and linked), or PARTIALLY applied (applied.failedConnectionIdsnon-empty — the write is N independent saves and cannot be atomic, and re-choosing the same arrangement converges). No arrangement is pre-selected, because the payload cannot report which is in force:openlinker-decidesswitches every assignable claim off, which on a fresh install is indistinguishable from nobody having configured anything. The row is a non-DataTableCSS-grid list registered indocs/frontend-ui-style-guide.md § Density & Row Heights— the only column cheap enough to hide at a breakpoint is the why-line, which § 3.3 calls the whole point of the table, so the row reflows to one column at <= 768 px instead. The generated-diff confirm dialog is #2355 and theNeeds attentionsection plus its cross-surface badges are #2356; this page renders neither. - The
?attention=orders axis (#2353) —OrderRecordFilters.omsAttentionplus anomsAttentioncount folded into the orders summary aggregate, both reading the one privateHAS_OMS_ATTENTIONpredicate. It is not theneeds_attentionHEALTH bucket, and the Swagger description opens by saying so: that bucket partitions the set and means a sync failure, this is an orthogonal axis meaning OpenLinker stopped deciding something, and an order is routinely one, the other, or both — so it is ANDed withhealthexactly likesalesDocumentBlockedandtaxRateConflict, never folded into it.countOrdersWithOmsAttention()is retained alongside the fold rather than superseded by it: the summary is filter-scoped so the/orderschip agrees with its own rows, while the who-decides page’s count is install-wide and has no filter scope to pass. One predicate, two scopes, no drift.
21. Order Lifecycle
Section titled “21. Order Lifecycle”See ADR-059 for the derived-phase decision and ADR-053 for the leaf posture.
- Responsibility: own the vocabulary the OMS lifecycle projection speaks — the derived
OrderLifecyclePhaseand its precedence order, the mergedHoldReasonunion,OrderAmendmentKind,LifecycleAuthoritywith its pure coercer, the internal-onlyOmsLifecycleFactunion, the one-wayphaseToOrderStatusprojection, andderiveOrderLifecyclePhase(#2307). - Location:
libs/core/src/order-lifecycle/. The second dependency-free vocabulary leaf, holding the same zero-sibling-edge property as 20. Fulfillment Authority above and pinned by the same spec. - One authorized carve-out:
OrderStatus/OrderRecordStatus/FulfillmentRollupState, imported type-only from the@openlinker/core/orders/typescycle-breaker sub-barrel — the same shapesales-documentsholds forOrder(ADR-041 decision 2). A type-only import erases at build time, so it adds no runtime edge. Restating those unions locally was considered and rejected: two sources of truth for a transport vocabulary is precisely the drift the mapping exists to prevent. Registered per leaf in the walker, never shared with a sibling leaf’s entry. - Naming note: the design prose says
OrderHoldReason; the shipped identifier isHoldReason, because the union is used at both the order and fulfilment-work grains. Do not “correct” it back.
22. Returns
Section titled “22. Returns”See ADR-060 for the decision rationale.
-
Responsibility: own the OL-owned return aggregate that sits ABOVE the source’s own return observation —
ReturnRecord(header) +ReturnLine— so that custody (“did the parcel arrive?”) and disposition (“did I restock it?”) are recordable at all. Those are events in the operator’s own building, with the operator as the sensor and no source counterpart to contradict; the pre-ADR-060 position (returns as a read-only source projection) could not express them. -
Location:
libs/core/src/returns/. A context of its own rather than a folder insideorders—ordersis already the most outbound-coupled context in the tree (8 edges), and returns carry authority questions that are the operator’s, not the source’s. -
The lifecycle is COUNTERS, not statuses (#2327):
quantityAdvised >= quantityReceived >= quantityRestocked + quantityScrapped, enforced by the DB constraintCHK_return_lines_quantity_orderingrather than by a domain method — so no caller, including one that bypasses this context, can persist an impossible line. Counters express partial receipt and partial disposition natively; a per-line status cannot. Alongside them run two orthogonal per-line machines that are never collapsed: custody (ReturnCustodyState) and money (ReturnMoneyState), because marketplaces routinely refund before the goods arrive, so a single “return state” would have to lie about one axis on the most common path there is. Custody has exactly five values and noinspected— collapsed intoreceivedbecause nothing writes it and no surface distinguishes the two; the reversal gate is aReturnReceiver/3PL flow where the receiving and inspecting parties genuinely differ (ADR-060 amended accordingly by #2327). Money carriesin_doubtbecause OL ships no refund WRITE: an execution OL cannot observe must be recordable as unknown rather than reported asrefunded. Disposition isrestock | scraponly —refurbish/RTV imply downstream processes OL has no entity for, the exact Wave-4 failure mode. All three columns land DEFAULTED and UNDRIVEN: #2327 ships the model and its schema, not the lifecycle (no state machine, no transition guard, no derived stage — the product spec’s § 3.2 rollup is a presentation projection). -
Custody is driven, and the rules are pure (#2367,
W2-30): the five-value custody machine gets its transitions indomain/domain-services/return-custody-transitions.domain-service.ts—advanceReturnCustodyToInTransit/applyReturnCustodyReceipt/applyReturnCustodyDisposition/markReturnCustodyNotReturned, plusisReturnCustodyFinished(the custody half of the list’sAll opensegment). Pure functions, not a service, the shape every other shipped rule engine in this tree takes (applyPricingRule,checkRequiredToSell,resolveSalesDocumentRouting,deriveOrderLifecyclePhase): there is nothing to inject, and #2370 computes its custody move INSIDE the transaction that already holds the row, so a service method would have invited a second read and with it a read-then-write race. They decide the resulting state,receivedAt/disposedAtand the counters the move implies; they persist nothing — #2370 (W2-33) owns the write,restock_blockedand the masteradjustInventorycall. Transitions are per line and quantity-aware, which is the whole reason custody lives on the line: a return received in two parcels legitimately holdsreceivedon one line anddisposedon another, andreceivedis re-entrant because the COUNTERS express partial receipt (adding a state to say what a counter already says is the double-encoding spec § 3.1 rejected). Three rules are decisions rather than implementation detail.in_transittakes the SOURCE’s instant, never OL’s — it is a claim about the outside world (a buyer handed a parcel to a carrier) that OL cannot witness, the same rule #2336 fixeddeclinedAtunder, so a 2xx can never stand in for a channel-reported fact; the other three states are entered by an operator acting inside OL, where OL’s clock IS the authority.not_returnedis always an operator act and never a timeout or a sweep (spec § 5.2) — which also settles the questionReturnRepository.warnOnVanishedLinesleft open: a line the source stops reporting is not auto-transitioned, because a source withdrawing a line says nothing about physical custody. Andnot_returnedis refused on a partially received line rather than approximated: custody is single-valued per line, the line still holds goods needing disposition, and there is noquantityNotReturnedcounter for the shortfall to move into — such a line finishes on the received branch with the shortfall permanently visible asquantityAdvised - quantityReceived, and giving it a state of its own is a model change with its own migration and spec amendment. Every refusal is one domain error (ReturnCustodyTransitionError) carrying a CLOSED reason union, so #2370’s actionable code and #2376’s 409 branch on a value rather than on a message string. Every switch closes withassertNever, which is what gives spec § 3.1’sinspectedreversal gate teeth: re-admitting the value makes each consumer a compile error rather than a silent fallthrough. No migration — the union shipped in its final five-member shape in Wave 1c. -
Custody is WRITTEN, and a line’s history is a set of ACTS beside the counters (#2370,
W2-33):IReturnCustodyServicegives the aggregate its two operator write paths —receiveLineanddisposeLine— plusmarkStockHandledManually, and is the tree’s first production caller ofInventoryMasterPort.adjustInventory. Alongside them lands an append-only per-line act ledger (return_line_events, migration1852000000000), written in the SAME transaction as the counters it explains. A counter cannot key an idempotent trigger firing: #2360 needs a three-parcel return to firereturn.receivedthree times, andquantityReceivedgoing1 -> 2 -> 3carries no per-arrival identity and is indistinguishable from a correction; it is also what finally gives #2368’sreturn:{returnId}:{lineId}:{seq}key aseqto name, and what letsrestock_blockedbe a property of ONE disposition attempt rather than of a whole line (a line may hold an applied restock and a refused one). The ledger is history BESIDE the invariant, never instead of it — the counters stay theCHK_return_lines_quantity_ordering-guarded columns and are never recomputed from the acts at read time, which would move the invariant out of the constraint’s reach. Five properties are load-bearing. (1) A blocked restock does NOT incrementquantityRestocked. The ACT is the disposition and is never rolled back (the goods really were disposed of), but the COUNTER records book-confirmed restock and a refused book write confirmed nothing — so the units stay inquantityReceiveduntil an operator attests (spec § 5.4), which is what lets #2381 assert that no surface renders blocked units as restocked.applyReturnCustodyDispositionis therefore called only AFTER the master answers and never on the blocked branch, which is why #2367 needed no amendment. (2) The counter check runs BEFORE the boundary crossing — the pure transition is called twice, once as validation (outcome discarded) and once at settle time against the locked row — because the other ordering lets an over-disposition increment the master’s book and be refused afterwards, leaving the book and the counters disagreeing with no act explaining it. (3) The act is persisted BEFORE the adapter call (ADR-056’s attempted-predicate ordering), so a process dying mid-call leavesin_doubtrather than silence;in_doubtnever auto-retries, since OL does not know whether the units landed and guessing moves real stock, and its remediation is the same attestation a block gets. (4) Disposal is serialized by a per-lineSyncLockPortlock (return:line:{lineId}) and every counter write takesSELECT … FOR UPDATE— two different guards for two different failures. The lock covers read-then-act across the provider boundary (theinvoiceIssueLockKeyshape, ADR-041 §3a: two concurrent disposals otherwise both pass the check and both apply under differentseqvalues, so no adapter can dedupe them); the row lock covers the lost update the CHECK is silent on, since receiving 2 twice againstadvised: 5and recording 2 is perfectly legal. (5) Every refusal is a named block, never a silent success —no-inventory-master/ambiguous-inventory-master(never a silent pick: a wrong book is real stock in the wrong place) /unresolved-product/ambiguous-product/master-refused/master-product-not-found/adapter-unresolved/unknown, each carrying the adapter’s own sentence verbatim because #2369 raises four distinct typed refusals that are four different operator actions and a closed union alone can only say “it refused” (#2231’s rule). The restock target is resolved by sku throughIProductsService— a return line carries no product id and cannot (resolvedOrderLineIdis a by-value reference into the order snapshot’s jsonb, written only at ingestion since #3171 and legitimatelynullfor an orphan or a line the resolver could not settle) — which is the one new module edge,returns -> products, acyclic. The attestation is addressed by LINE (matching #2376’s route and spec § 5.4’s per-line action), settles every outstandingblocked/in_doubtact, recordsrestockedBy: 'operator_out_of_band', and never writes stock or claims OL did; the timeline keeps both entries permanently and only the attention clears. No marketplace write originates here — the ordinaryinventory.propagateToMarketplacesfan-out carries the master adjustment onward, asserted by a spec. Known limitation, named rather than approximated:not_returnedon a partially received line stays refused (#2367’s open question, settled here) — the shortfall needs a COUNTER (quantityNotReturned+ a widened CHECK), which an acts ledger does not supply, so spec § 5.2’s “Mark remainder not returned” is unimplementable for such a line and the shortfall stays visible asquantityAdvised - quantityReceived. -
A restock goes back into the master that OWNS the line (#3486, epic #3460): #2370 resolved ONE global
InventoryMasterand refused whenever there were several, so with the two product masters v1 allows feeding one warehouse (#3457) no returned unit could be put back on stock.ReturnCustodyServicenow builds a per-line restock plan. With exactly oneInventoryMasterconnection nothing changes - that master takes the units and provenance is not consulted, so a single-master install whose positions predate provenance (NULL/'legacy') keeps restocking. With several, the line’s product is resolved first and its owner is then read from position provenance throughIInventoryQueryService.resolveStockOwner, which applies the SAME pure rule the routed-order sale decrement (#3453) acts on -resolveInventoryPositionOwnerininventory-owner.types.ts, extracted fromresolveSaleDecrementOwnerso the two writes cannot disagree about who owns a product; only the operator-facing sentence differs. Three new named blocks cover what cannot be decided -no-position,unattributed-owner(NULL/'legacy'provenance) andambiguous-owner(a product held in both masters) - and an owner that is not among the activeInventoryMasterconnections blocks asadapter-unresolved, never falling through to the other master. Known gap: the return-detail readgetRestockTarget()is still return-level and answersambiguous-inventory-masterwith several masters, so the UI keeps the Restock action disabled there even though the write now resolves per line; making that disclosure per line is a follow-up. -
ReturnRecord.internalOrderIdis nullable BY DESIGN, and that nullability is the feature. A return can name an order OL has not ingested, ingested under another connection, or will never ingest — while a parcel is nonetheless on its way to the operator’s building. So an orphan return persists, surfaces in an operator bucket (backed by the partial indexIDX_returns_orphans), and blocks every downstream trigger: nothing is restocked, refunded or corrected against an order OL cannot name. Nullability makes “unattributed” a representable, visible state instead of an insert that fails in a job log.ReturnLine.resolvedOrderLineIdis nullable for a stronger reason still —order_recordshas no lines table (items live inside theorderSnapshotjsonb), so the value is a by-value reference INTO a document and a foreign key is not merely undesirable, it is impossible. Neither column carries an FK; therefund_records/invoice_recordsprecedent (indexed reference by value, no cross-table lock coupling) governs. The ONE foreign key in the schema isreturn_lines.returnId -> returns(id) ON DELETE CASCADE— a line is a part of its header, not a peer — declared in the migration only, with no@ManyToOne. -
The source’s words are stored verbatim and never interpreted:
rawStatus+rawPayload, with no mapping table and no status column derived from them. No source reports a lifecycle OL can derive (Allegro’s status is an 11-value timeline; Erli’s returns carry neither id nor status), so authorization is an ACTION, not a state (ADR-044): the header carries four INDEPENDENT nullable timestamps —openedAt/authorizedAt/declinedAt/closedAt— none of which excludes another.rawPayloadis a known PII gap, landed and named rather than silently carried: it has noOL_STORE_PIIparity withcustomer_projections, which is an INGESTION concern (#2330) and cannot be decided against a payload shape that does not exist yet. -
RefundRecordis LINKED, not extended (#2327):refund_recordsgains a nullablereturnId(plus a partial index) and nothing else changes — a returns int-spec snapshots the table’s whole column list so that claim is a test rather than a promise. Refunds exist without returns (goodwill, price correction) and returns without refunds (a warranty swap), so wideningRefundRecordwould have restated a shape whose every live row predates returns and already feeds analytics. The column is persistence-only in this slice: the domain entity, its create-input and every read projection are untouched, because projecting a field no writer populates would put a permanently-null property on an API response.ReturnLine.reasonreusesRefundReasonverbatim (via the@openlinker/core/orders/typescycle-breaker sub-barrel, so noOrdersModuleedge exists) precisely so returns-by-reason and refunds-by-reason report on ONE axis. -
Not a zero-sibling-edge leaf, unlike
sales-documents/fulfillment-authority/order-lifecycle/fulfillment:returns -> orders(RefundReason+RefundReasonValues, type-only) and, since #2328,returns -> identifier-mapping(a real module edge for order attribution) are both real, so the context is deliberately absent fromZERO_SIBLING_EDGE_LEAVESinbarrel-purity.spec.ts.ordersmust never importreturnsback — a return-shaped read added to an orders service would close a CJS module-load cycle; it belongs on this context’s ownIReturnsService(#2328), which is also why a sibling reaches the aggregate through that interface and never throughRETURN_REPOSITORY_TOKEN. It is likewise not re-exported from the aggregating root barrel (thesales-documentsposture), and is reached at its own@openlinker/core/returnssubpath. -
Ids are
ol_return_*viaformatInternalId('Return')with ZERO registration — noENTITY_TYPE_ID_PREFIXoverride and noCoreEntityTypeValuesmember, because that union is the external-mapping vocabulary and a return is not mapped throughidentifier_mappings:externalReturnIdis a column on the row, resolved by the partial unique indexUQ_returns_source_external(the(sourceConnectionId, externalReturnId)update-or-create key ofDESIGN-oms-authority-model.md§ 7.3). The index is partial because a source that mints no return id (Erli) writes NULL, which must not collide — whether such a source should instead be given a SYNTHETIC key is #2328’s gate decision, and the model stays neutral about it.ReturnLine’s PK is a plain uuid: a line is never referenced from outside the aggregate (refund_recordsprecedent). -
Ingestion is an idempotent update-or-create, and its key must exist (#2328):
ReturnsService.upsertFromObservationmaps the neutralIncomingReturnprojection onto the aggregate and writes it throughReturnRepositoryPort.upsertFromSource— header and lines in ONE transaction, oneINSERT … ON CONFLICT DO UPDATEper table, so a re-sync converges on the same rows instead of accumulating duplicates. The header’s conflict target carries the partial index’s own predicate (("sourceConnectionId","externalReturnId") WHERE "externalReturnId" IS NOT NULL; theproduct_content_fieldprecedent), which is precisely why a NULL key is refused rather than written: NULLs are distinct under that index, so a null-keyed ingestion has no conflict target and would duplicate the return on every re-sync, unbounded and silently. Core therefore raises the non-retryableReturnObservationMissingExternalIdErrorand the ADAPTER synthesises a key for a source that mints none — deterministic, built only from source-stable coordinates, namespaced (recorded Erli form:erli:{externalOrderId}:{index}, whose array-reorder weakness is accepted and named). NULL stays legal in the model for anoperator_authoredreturn, which is created throughcreate()and never ingested. Attribution is a lookup and it is monotonic:getInternalId, nevergetOrCreateInternalId(which would mint an internal id for an order OL has never ingested and point every downstream trigger at a phantom), andinternalOrderId+openedAtare applied withCOALESCEso a later write may fill them in but a failed re-resolve can never re-orphan an attributed return. The write set is the contract:authorizedAt/declinedAt/closedAtand every Wave-2 line column (the counters beyondquantityAdvised, custody, money, disposition,receivedAt,disposedAt,resolvedOrderLineId) appear in NEITHER half of either statement — a marketplace cannot observe the operator’s own building, so re-ingestion must never be able to un-receive a parcel; the returned record consequently reports those three timestamps asnullwhatever the row holds, and a caller needing their true value re-reads viafindById. Lines are upserted per(returnId, lineIndex), never replace-all (which would destroy custody state and churn line ids for a parcel physically in transit), and a line the source stops reporting is kept and warned about rather than deleted. There is deliberately nocreatedflag:ON CONFLICT … DO UPDATEalways produces a row, distinguishing insert from update would need the MVCC internalxmax = 0(no precedent in this tree), and the property that matters — a replay leaves ONE row — is a claim about the table, asserted by counting rows. No lock (the transaction is atomic), and no event — nothing in this wave subscribes to one. -
A returned line is joined to the order line it came from, and the join is core’s (#3171):
ReturnLine.resolvedOrderLineIdholds theorderSnapshot.items[].idthatreservations.orderLineId,shipment_lines.lineIdandFulfillmentWorkLine.orderLineIdalready key on. It existed as the designed home for that join and nothing populated it, so the credit-note correction path (#2374) fell back to matching disposed return lines against the issued invoice by product name — the one axis guaranteed to collide — and then escalated the collision to the operator as anambiguousline with a two-option picker. That question is both unanswerable and manufactured: the operator holds a parcel whileInvoiceLinecarries no id and no SKU, and OpenLinker builds the invoice itself fromorder.itemsin order (order-to-issue-invoice-command.mapper.ts:113), so invoice line N isorder.items[N-1]deterministically while the return line arrives carryingskuandunitPrice. Four properties are load-bearing. (1) The rule REFUSES rather than guesses —NrWierszaFais mandatory in every permitted FA(3) correction method, so a guessed line is an unauditable entry in a filed document.resolveReturnLineOrderLinenarrows on SKU (exact after trim, and deliberately not case- or diacritic-folded: a SKU is an operator-authored identifier, not display text), then on the source’s ownofferIdwhen no SKU was reported — Allegro’s return payload carries no SKU at all, onlyofferId, while its order-line mapper writessku: lineItem.offer.id, so on the only shippedReturnSourceReaderthe two sides report ONE identifier under two field names andofferIdis the axis that actually resolves a line, notsku. Either identity axis breaks a same-identity tie on the reported unit price; more than one survivor isambiguous, and the matcher’s unresolved path stays reachable. Product NAME is not an axis, because its collisions are what the rule removes. (2) The axis order is a NARROWING, never a retry — a SKU that matched nothing does not fall through to price, which would resolve a line the SKU positively excluded: a wrong answer rather than a missing one. (3) The write is a fill-in-when-NULL claim (claimOrderLineResolution, theclaimAttributionidiom), so a later or failed re-resolve can never un-resolve a line, and the column stays absent from BOTH halves ofupsertFromSource— it is core-resolved and must never be echoed by a source, whichlibs/core/src/returns/__tests__/order-line-resolution-is-core-owned.spec.tspins. (4) The ORDER READ lives in the worker, not inreturns. The rule and the write are core’s; only the cross-context read is composed byReturnOrderLineResolverService(apps/worker/src/sync/), which readsOrderRecord.orderItems— the owning context’s own narrowing of the snapshot — and passes it in as an argument (ADR-053’s “order data enters as arguments”).returnstherefore takes no new module edge:OrdersModuleimports fourteen modules, nine of them new to returns, for onegetOrderRecordcall, and the repository has twice preferred the worker for exactly this shape (OrderHoldsModuleexists as a leaf “soOrderHoldService… can take it WITHOUT the eight-context graph above”; #2712 reports from core and writes from the worker, adding zero guard allow-set entries). The pass is best-effort and never fails its job — the return is already durably persisted when it runs — and idempotent, which is what makes it safe to re-run and the only route by which a return ingested before this shipped ever gets resolved. Limitations are stated rather than implied: anambiguousorno-candidateoutcome is reported only inReturnOrderLineResolutionSummaryand logged atwarn— no operator surface or metric counts it yet, so today it is visible only in the log line, which is exactly the duplicate-line case the picker exists to settle; themarketplace.returns.statusSynclifecycle re-read does not resolve, becauseReturnStatusSyncResultreports counters only and carries no list of the returns it touched; and an orphan return matched to an order by hand (matchOrphanToOrder, #2376) does not itself re-trigger this pass, so a manually-attributed return’s lines stay unresolved until the next per-return sync. Consuming the field — narrowingreturn-correction-matching.domain-service.tsonto it and retiring the picker — is a follow-up; this slice makes the field trustworthy and changes no existing behaviour. -
Ingestion is TWO passes, and that is forced by the source rather than chosen (#2330, ADR-060): a return feed can only report that a return EXISTS. Allegro’s
CustomerReturncarriescreatedAtand noupdatedAt, and/order/eventshas no return event type at all (SPIKE-2289 E7/E8), so a cursor defined over creation can never observe a return movingCREATED -> DELIVERED -> FINISHED. Re-reading the id is not a fallback here, it is the ENTIRE change-detection channel — an implementation that collapses the two passes silently stops observing every transition after creation, which from the outside looks exactly like a marketplace where nothing is ever refunded. Hence three job types, not two:marketplace.returns.poll(fan-out, cursor-paged discovery) fans outmarketplace.return.sync(realtime, hydrate + idempotent upsert), whilemarketplace.returns.statusSync(bulk) re-reads OL’s own non-terminal returns inline — themaster.product.syncAll/master.product.reconcilesplit applied to returns. Pass 2 enqueues nothing, so a page of lifecycle work can never fan out into an unbounded child wave. Lane tally after this change: 13 realtime / 13 bulk / 5 fiscal / 7 fan-out. -
The cursor advances only after every child enqueue succeeded (#2330):
ReturnIngestionServicemirrorsOrderIngestionService— per-connection lock, deterministicmarketplace:{cid}:return:{eventKey}dedupe keys, enqueue-then-commit (the #2218 rule). Committing a cursor while a child failed to enqueue converts a retryable hiccup into permanent silent loss, and a lost return is a buyer waiting for money nobody will be told about; re-reading a page is free because the downstream write is idempotent. TheisCursorRegressionguard is deliberately NOT ported. A return cursor is a UUID, soNumber()yieldsNaN, the comparison falls through to a lexicographic test over random hex, and the guard would refuse roughly half of all legitimate advances at random and wedge the connection the first time it did.ISyncCursorsServicestates that “monotonicity is the caller’s responsibility”; this caller’s honest answer is to commit only a non-empty, CHANGED cursor and otherwise hold — which prevents the two failures a guard could actually prevent here (a blanked cursor from a degraded response, a page reprocessed forever from an echoed one) without inventing an ordering UUIDs do not have. -
Pass 2 is bounded three ways, and the age bound is NOT optional (#2330): (1) the adapter’s declared
ReturnSourceReader.terminalRawStatuses— an OPTIONAL member amended onto #2329’s contract in the same wave (the guard is unchanged, still keying on the two methods, so a pre-amendment adapter remains a fullReturnSourceReader) — applied by core as an OPAQUENOT INover the storedrawStatuswith no interpretation of any member, which is what keeps the source’s status language adapter-side; (2) an age bound, because bound 1 is only as good as the adapter’s list — a status the source adds and the adapter has not learned would otherwise pin those returns in the candidate set PERMANENTLY, the sweep’s cost growing with the connection’s whole history rather than with its open work; (3) a page budget with a rolling scan offset onconnection_cursors(themarketplace.offer.statusSync#816 shape, and the handler owns the offset viaConnectionCursorRepositoryPortexactly as that sibling does, not viaISyncCursorsService). The offset advances by rows READ, not rows persisted, so a permanently-failing row cannot park the sweep and starve every other open return; a 404 is counted and the page CONTINUES. An adapter declaring no vocabulary degrades to bounds 2+3 — reported asterminalVocabularyDeclared: falserather than left looking like a defect — and the empty list is OMITTED from the SQL, sinceNOT IN ()is a Postgres syntax error. Terminal-at-source never means terminal-at-OL: it decides only what to stop ASKING about, never an OL disposition, and self-healing follows for free (a re-read that persists a terminalrawStatusdrops the row from the next candidate set by itself). -
Allegro is the first implementer, and its two scheduler tasks ship OFF (#2330):
AllegroOrderSourceAdaptergainslistReturnFeed/getReturnover the[BETA]customer-returns resource (media type set PER REQUEST via the caller-header hook, since every other Allegro call wantspublic.v1and a client-wide default would silently retag them all), with the wire types and the single terminal constant inallegro-customer-return.types.tsand the projection extracted toallegro-customer-return.mapper.ts(theallegro-payment-status.tsprecedent — the adapter was already 780 lines). ONE constant feeds bothterminalRawStatusesand the per-observationisTerminalAtSourcehint, spec-asserted, because deriving them independently is exactly the drift that produces a return excluded from the sweep while still reported as open. It sendsfromand neveroffset(deep offsets 504), terminates on an empty array and never oncount(whose semantics are unstated), never composesfromwith a filter (the composition order is undocumented — risk 1 is designed around, not guessed at), and bootstraps acreatedAt.gtewindow behind the opaque cursor when there is no cursor yet, so core never learns that this source bootstraps by date and pages by id.items[].priceis Allegro’sPricewith a stringamount(verified against the spec, which the spike sketch left untyped), parsed to a number with an unparseable value reported as absent rather thanNaN.reason.typerides through verbatim anduserCommentis deliberately NOT merged into it — buyer prose would defeat core’s reason mapping for exactly the returns whose buyer bothered to explain themselves — whilerefund/parcels/rejectionare projected onto no neutral field and survive inraw.ReturnSourceReaderjoins the manifest as advertised-without-dispatch while both scheduler tasks requireOrderSource: complementary facts, not a contradiction. Both tasks default OFF, unlike every other Allegro task — the endpoint is[BETA](its media type may change without the usual deprecation ladder), its cursor ordering guarantees are undocumented, and nobody has exercised it against real buyer-initiated returns (SPIKE-2289 risks 1/3/6/7; risk 6 is CONCEDED — every test is fixture-driven and markedneeds-production-probe), so adding recurring marketplace load to every existing connection at deploy stays an operator decision until there is field evidence. -
The orphan bucket, the downstream-trigger block and re-attribution (#2332):
internalOrderIdis nullable so an unattributable return can PERSIST, and #2332 is what makes that state visible, inert and self-healing. One definition of orphan —ReturnRecord.isOrphan()(a pure ADR-011 derivation overinternalOrderId === null), from which the count, the bucket vocabulary, the guard and the candidate query all derive; a second rule spelled anywhere else is how the bucket and the block start disagreeing about the same row. The block throws, and it re-reads.IReturnsService.assertAttributedForTrigger(returnId, trigger)is the single seam every Wave-2 flow calls (ReturnDownstreamTriggerValues=restock/refund/invoice_correction/decline), raising the namedReturnNotAttributedErrorcarrying BOTH the return id and the refused trigger; a boolean would be ignorable, and the whole point is that a trigger cannot proceed by omission — a restock against a phantom order moves real stock and no later log line recovers it. It re-reads the row rather than trusting a caller-held record (which may predate a reconcile, or be anupsertFromSourceresult whose OL-owned timestamps are deliberately blanked) and returns the hydrated aggregate, so a caller cannot act on a different read than the one it checked;ReturnNotFoundErroris kept distinct, because telling an operator to attribute a return that does not exist is a different instruction. The issue’s premise was wrong and the fix is a column: re-attribution was specified as “keyed on the source order id already stored on the record”, but that value was read once inresolveInternalOrderIdand discarded — it was not a column and not inrawPayload— soreturns.externalOrderIdis added (migration1850000000007, plus a partialIDX_returns_orphan_reattribution) and applied with COALESCE likeopenedAt, never latest-wins likerawStatus: a source that stops naming the order has not made the return belong to a different one, and blanking it would destroy the only key the reconcile can resolve from. Rows written before the migration are not backfilled — the value was never persisted anywhere, so there is nothing to backfill from. The reconcile inverts the direction ingestion runs in, which is why ingestion cannot be the trigger:returns.orphan.reconcile(bulklane,ReturnReattributionService, capability-scoped toOrderSource,*/30, and default ON unlike the two #2330 ingestion tasks because it contacts no marketplace at all — oneidentifier_mappingsread and one local UPDATE) enumerates OL’s OWN orphans and asks whether the order has since arrived. The write isclaimAttribution, a conditional UPDATEWHERE "internalOrderId" IS NULL(theclaimWaybillRelayshape) that is both the race seam against a concurrentupsertFromSourceand the structural guarantee that attribution stays monotonic — this statement can fill the value in and can never change one. Candidates are ordered newest-first, deliberately the opposite offindForSourceSweep’s oldest-first, because the question is “whose order most likely just arrived” rather than “who has waited longest for a re-read”. Four counters, not three: a lost claim race isalreadyAttributed, neverunresolved(which would state that a return is still orphaned when a peer just attributed it) and neverfailed(the desired end state was reached). And the catch is narrow — only the per-row write is caught; a connection-resolution throw propagates into the job’sSyncJobExecutionError, because catching it per candidate would launder a deleted connection intofailed: Non every page, every tick, forever, with nothing abovewarn. The pass cannot fail ingestion structurally: it is its own job type on its own lane reached through its own service, and no ingestion path calls it. Lane tally after this change: 13 realtime / 15 bulk / 5 fiscal / 7 fan-out.declineis a member of the trigger vocabulary for a reason that is easy to misread as a category error: #2333’sreturn.declineis an OL→source WRITE, not a §7.3 downstream consequence, but the union is not a taxonomy of flows — it is the vocabulary the attribution guard refuses by, and #2333’s own R3 states the orphan refusal structurally (an ADR-044 proposal has a NOT NULLinternalOrderId).ReturnDeclineServicetherefore asserts attribution throughassertAttributedForTrigger('decline')rather than its owninternalOrderId === nullcheck. #2332 and #2333 were built concurrently and each defined a rivalReturnNotFoundErrorandReturnNotAttributedError; there is now exactly ONE definition of each (the per-file #2332 pair), because two same-named classes failinstanceofagainst each other silently — the HTTP filter would answer 500 for a refusal the service raised deliberately. -
The authorize WRITE, the orphan MATCH and the operator-authored CREATE (#2372,
W2-35): three writes that share one theme — OL acting where no source has an opinion.return.authorizeis restricted toorigin: 'operator_authored', which IS the slice: ADR-060’s asymmetry says OL declines where the platform allows it (#2333) but authorizes only what OL itself authored, so asource_ingestedreturn is refused with a named error (ReturnAuthorizeRefusedError, closed reason'source-ingested') rather than a silent no-op — an operator clicking Authorize on a marketplace return must learn why nothing happened. It reuses the ADR-044order_changestable (#2333) with a secondkind, never a second proposal mechanism (asserted bylibs/core/src/returns/__tests__/no-second-proposal-mechanism.spec.ts, which also bans a returns-side import ofOrderChangeRepositoryPort— a second route into the same table would bypass the service owning TTL expiry). Four properties are decisions. It crosses no adapter boundary at all — for a return OL authored there is no source to ask, so the row is the AUDIT RECORD of the operator’s act rather than a request awaiting an answer, opened and confirmed in one breath.authorizedAttherefore takes OL’s own clock, which is the correct side of the #2336 / #2367 / #2371 rule: that rule forbids OL’s clock standing in for a CHANNEL-reported fact, and this is OL’s own act with OL as the sensor (theReturnLineEvent.occurredAtside, oppositeclaimDeclinedAt). A reused open proposal does NOT abort, deliberately unlike decline: the ADR-044 slot exists to stop a duplicate REMOTE request and there is none, so refusing would let one crash betweenopenOrReuseandconfirmwedge the return behind a full TTL for no safety gain — the at-most-once guarantee isclaimAuthorizedAt’s single conditionalIS NULLUPDATE, exactly asclaimDeclinedAtis. And there is no guard ondeclinedAt/closedAt: the four timestamps are four independent facts and none excludes another, so a guard would quietly reinstate the status ladder the model refuses.'authorize'joinsReturnDownstreamTriggerValuesso the orphan refusal comes from the ONE seam and raises the same class every other trigger does.matchOrphanToOrderis the operator’s way out of the #2332 bucket, beside the background reconcile, and unblocks every downstream trigger by construction —assertAttributedForTriggerre-reads the row, so fillinginternalOrderIdis the whole of what a refund, a restock or a correction needs and no consumer changes. The order’s existence is proved throughidentifier_mappings, not throughorders: an internal order id exists there iff OL minted it while ingesting, which is a sound existence proof reached through a dependency this context already injects — and it costs noOrdersModuleedge, whichReturnsModule’s docblock warns against manufacturing. It proves an id was MINTED, not that anorder_recordsrow still stands, the same by-value postureinternalOrderIdalready has. The lookup is unscoped by connection on purpose: an order ingested under a DIFFERENT connection is one of the documented orphan causes, so scoping to the return’s ownsourceConnectionIdwould refuse precisely the case the action exists for. The write isclaimAttribution, so attribution stays monotonic and there is no unmatch — an operator can fill the value in and never change one, which makes a mis-match uncorrectable in the product and is why #2376 must render a confirmation rather than a bare button; a lost claim race reports'already-attributed', never a failure. The act is recorded on two additive nullable columns (returns.matchedAt/matchedByUserId, migration1860000000000) rather than inreturn_line_events, because that ledger is per-LINE and its rows sum back to the counters while a header-level attribution has neither a line nor a quantity; NULL means “not matched by an operator” and is not backfilled, since a return attributed at ingestion or by the reconcile had no operator act to record. Line resolution is not re-triggered by this action —resolvedOrderLineId(#3171) is populated byReturnOrderLineResolverService, whose only caller is themarketplace.return.syncper-return path, whichmatchOrphanToOrderdoes not invoke (nor does themarketplace.returns.statusSynclifecycle re-read, per the bullet above); a manually-matched orphan’s lines therefore stay unresolved until that per-return sync runs for the return again rather than immediately, a stated limitation rather than a gap in the model.recordReturnopens a return in OL against an order OL already knows, for a source with no returns surface — the first production caller ofReturnRepositoryPort.create, which has had none since #2327. It writesorigin: 'operator_authored'andexternalReturnId: null, never synthesised:UQ_returns_source_externalis partial precisely so such a row writes NULL, and a fabricated id would make the row claim a source it has not got (a source ADAPTER minting a deterministic key for its own platform, as Erli does, is a different and established thing).sourceConnectionIdis REQUIRED and VALIDATED rather than derived — the column isuuid NOT NULLso the row must name a channel — and the mapping on that connection is what suppliesexternalOrderId, so the value is a fact OL already holds rather than an operator-typed string.openedAttakes OL’s clock;authorizedAtstays NULL, because recording and authorizing are two acts, which is the entire premise of “authorization is an ACTION, not a state” and what leavesreturn.authorizea job to do. Lines land at the schema defaults, which is literally what makes an operator-authored return participate in custody and money exactly like an ingested one. Calling it twice creates two returns, and that is accepted: there is no natural key to dedup on, an operator may legitimately open two returns against one order, and core will not synthesise one to prevent it — a caller wanting at-most-once owns that above the seam.REFUND_ATTEMPTABLE_MONEY_STATESdeliberately does NOT narrow here. #2371 records that the set containsnot_refundableonly because it is #2327’s column default, and that a slice settingpendingat creation narrows it topending | deniedwith no call-site change. This slice declines that step: narrowing is sound only once EVERY writer setspending, and source-ingested lines — every existing row in every install — would still carrynot_refundable, making them permanently unrefundable, a silent money defect discovered one refund at a time. Settingpendingfor operator-authored lines only buys nothing (isRefundAttemptabletreats the two identically) and costs consistency, while sharing the default is the literal reading of “participates in money exactly like an ingested one”. The narrowing needs three things in ONE commit: ingestion writingpendingon insert, a backfill migration for existing rows, and only then the one-line edit to the set. -
The credit-note correction PROPOSAL, and the positional ambiguity it refuses to resolve (#2374,
W2-38): a disposed return line PROPOSES an invoice correction, and the proposal is data —ReturnCorrectionProposalServicereads, classifies and records; it resolves no adapter, references neitherissueCorrectionnorCorrectionIssuer(asserted by grep in__tests__/proposal-never-issues.spec.ts), and holding a proposal confers no authority to issue one. Issuing stays the operator’s confirmed act through the existing correction flow, because a correction transmitted to a tax authority cannot be withdrawn. The ambiguity is the whole subject.CorrectionLine.originalLineNumberis a 1-based ARRAY POSITION,InvoiceLinecarries no identifier and no sku, andReturnLinecarries no price — sonameis the only axis the two shapes share, and an order that repeats one offer across two lines (the shape SPIKE-2375 found for Allegro commission refunds, where a claim keys on an order LINE ITEM) produces two identically-named invoice lines that no data in either record can tell apart.ambiguousis therefore a first-class outcome that lists EVERY candidate and selects none — never collapsed intomatchedeven when every candidate carries the same price and rate, since picking one on an amount coincidence stamps a specific line number into a fiscal document.candidatesPriceOrRateDifferreports that coincidence as evidence for the operator instead. Five properties are decisions. (1) ThetargetRefiscorrection:{returnId}:{invoiceRecordId}, becauseUQ_order_changes_open_targetis(internalOrderId, targetRef)and carries nokind: the barereturnIdnamespace is taken byreturn.decline/return.authorize, while keying on the invoice id alone would collide across RETURNS — an order legitimately produces several (partial returns arriving in waves), and the second build would terminalise a proposal the first return’s operator was mid-review on. (2) The disposed quantity comes from the CHECK-guarded COUNTERS, never a sum over the act ledger — so a #2370 blocked restock, whose units never reachedquantityRestocked, contributes nothing; that line is excluded LOUDLY asno-match/disposition-not-confirmed(read once per build fromfindOutstandingRestockEventsForReturn) rather than vanishing, because crediting a buyer for units OL could not confirm came back is exactly the guess this programme refuses. (3) The delta is quantity only — a return does not change a unit price, so core computes no net and rounds nothing (ADR-063). (4) A pre-#1297 document is REFUSED, never diffed against the order’s current state, which is precisely the defect the issued-line snapshot exists to prevent; and the target is the latestissuedrecord, which makes #1297’s correction-of-a-correction rule true by construction rather than by a special case, since a successful correction is itself anInvoiceRecord. (5) Every non-proposing exit is a named outcome (no-invoice/no-line-snapshot/no-disposed-lines/nothing-correctable), andnothing-correctablestill returns the full proposal body so the operator reads every exclusion reason while no ADR-044 slot is held for a proposal with nothing to confirm. The persisted row always matches the answer — an identical open row is reused, a diverged one isabandoned and replaced (comparison is over a key-sorted canonical form, because Postgres normalises jsonb key order and a plainJSON.stringifywould make the reuse branch unreachable) — and it holds no operator picks: a pick belongs to the confirm request, or the next build destroys it. Names are matched exact-after-normalisation (trim, collapse whitespace, case-fold) and deliberately not diacritic-folded, unlikeDestinationCategory.searchText: both names descend from the same catalogue, so folding buys nothing and could collapse two distinct products into a manufactured ambiguity. No migration —order_changes.kindisvarcharwith no enum and no CHECK. The HTTP surface is #2376 and the review UI #2382. The matcher is now id-first (#3312).InvoiceLine.orderLineIdcarriesOrderItem.id(set bytoInvoiceLine, never bytoShippingLines— no single order item backs a delivery line, which stays #3290’s concern) andReturnLine.resolvedOrderLineId(#3171/#3172) is looked up against it BEFORE any name lookup — deterministic, no candidates surfaced, when it resolves to exactly one snapshot line. By-name matching is the FALLBACK, unchanged, for a return whose own join is unresolved (a routine, ongoing state, not only a historical one — the resolver can genuinely fail to disambiguate a SKU) or an invoice predating theorderLineIdfield.status: 'ambiguous'is retired as a live outcome, not removed from the type — the same underlying condition (id lookup finds >1 candidate, or the by-name fallback still finds >1 feasible one) now reportsno-match('ambiguous-invoice-line'), a highlighted, non-blocking row rather than a candidate list, matching #3091’s retirement of the picker interaction itself. -
The write API, and the two statuses that carry meaning (#2376,
W2-39): every Wave-2 returns write gets an HTTP surface on a newReturnWritesController— the three per-line custody acts,authorize,match-order, the operator-authoredrecord, the refund confirmation, and aGET/POSTcorrection-proposal pair. Guarded by the existing@Roles('admin','operator')model with no new permission value, and the actor is always the verified token’s, never the body’s. Five things are decisions rather than plumbing. (1) A blocked restock is a 2xx, carryingquantity,skuandconnectionNameverbatim: the disposition succeeded and is recorded, only the master write failed, and the remediation copy names all three so a UI that had to fetch them separately would render the alarm a beat late. (2) A counter-invariant violation is a 409 carrying an actionablereason— the closedReturnCustodyTransitionError.reasonis emitted as a FIELD, exactly asReturnNotAttributedError.triggeralready is, because a client that parsed the message would break on the first reword.non-positive-quantityis 409 too despite reading like a validation fault: the DTO’s@IsInt() @Min(1)catches every malformed request first, so reaching the domain check means the value was well formed and the STATE refused. (3)ReturnsExceptionFiltergrew from four mappings to fourteen, because an unmapped domain error is a 500 for a state the service raised deliberately — the failure that filter exists to prevent. They follow one stated rule (404 = the addressed resource is absent; 409 = it exists and its state refuses; 400 = the payload was inapplicable), which is what SPLITSReturnMatchRefusedErrorby reason:already-attributedis the return’s own state (409), whileunknown-ordermeans the return is fine and the operator’s order id names nothing OL minted (400) — answering 409 for both would tell an operator the return was in a bad state when their typo was the problem. (4) The refund route performs the second write itself and never rolls back.ReturnRefundServicereports an intent (#2371’s report-don’t-persist seam, which is what keepsOrdersModuleout of core’sReturnsModule); the controller writes the linkedRefundRecordthroughIOrderRefundServiceunder a per-attempt idempotency key derived from the claimed line ids. The two writes are not atomic and cannot be — two contexts, two repositories — so a failed record write answers 2xx withrefundRecordWritten: falserather than a 5xx: the money state already settled durably, reporting failure would send the operator to retry, and the retry would answer 409already-attempted.moneyMovedandrefundRecordWrittenare separate booleans precisely so “nothing to write” and “money moved, record pending” are not both a null id. (5)GET /returns/:id/correction-proposalcannot write, so #2374’s service gainedpreviewProposal: both entry points delegate to one privatecompute, which makes “the preview and the recorded proposal agree” structural and “the GET persists nothing” true by construction. Binding the GET tobuildProposalwould have opened, abandoned and replaced ADR-044 rows on every page load.POST /returns/:id/commission-refund/claimis deliberately absent —W2-37(#2379) has not landed and SPIKE-2375 leaves the shape undecided, so a 501 placeholder would advertise a capability OL has not got and an invented attestation contract would settle at the HTTP layer a question the spike exists to answer. -
The derived operator STAGE, implemented twice and pinned (#2377,
W2-40, spec § 3.2): the returns list renders one glanceable stage per row —Awaiting parcel/Partially received/Received — awaiting disposition/Disposed/Not returned/Declined— computed purely from counters and timestamps. It is a presentation projection and never a persisted column; an int-spec asserts nostagecolumn exists onreturnsorreturn_lines, because the cheapest way for one to arrive is a “while we’re here” edit to a migration about something else, and persisting it would be a model change needing its own ADR. Six properties are decisions. (1) The array order IS the ordinal — first match wins and the SQLCASEis BUILT by iteratingReturnStageValues, so a reorder changes behaviour andscripts/check-return-stage-mirror.mjstreats one as a hard failure (the #2311OrderLifecyclePhaseValuesrule). Spec § 3.2 lists the six in narrative order, which is reading order for an operator and deliberately not encoded anywhere — one order, one rule. (2)partially_receivedoutranksdisposed: a return with 2 of 5 units arrived and both disposed is not finished, and calling itDisposedwould say it is closed when three units may still turn up. (3)advisedin every arm means STILL EXPECTED, not originally announced.markReturnCustodyNotReturnedrefuses a partially-received line, so anot_returnedline always carriesreceived = 0while itsquantityAdvisedwould otherwise sit in the denominator forever — a two-line return with one line disposed and one written off would renderPartially receivedpermanently, a false statement about work the operator has finished, which thenot_returnedarm cannot rescue because that arm needs EVERY line written off.notReturnedQuantityAdvisedis subtracted; the semantic shift is the rule, not a patch on the arithmetic. (4) The FE derives it; the API does NOT ship astagefield. The row already holds the counters, so asking the server for a string it can compute is a round trip for nothing — and astageon the wire would leave nothing to mirror, which is the point. The list projection therefore gained a per-return counter rollup (ReturnCountersDto), aggregated in SQL because a header-shaped read must not hydrate every line of every row to compute six integers; the DETAIL read leavesReturnRecord.countersnulland folds its real lines instead, andnullmeans “this read did not load counters”, never “all zero”. (5) The mirror script pins structure, never semantics — two rules of different strength, value+order equality core↔FE and structural-only core↔RETURN_STAGE_PREDICATES(same keys, same order, still consumed by aReturnStageValues.map(). Its docblock forbids strengthening rule B, and the temptation is sharper than in the order-lifecycle original: that one justifies its limit by pointing at threeFALSEplaceholder arms, whereas every return-stage arm is semantically real, so a later reader will conclude a semantic comparison is now achievable. It is not — a TS function over numbers and a SQLCASEover aggregate columns admit no textual equality. Meaning is proved byRETURN_STAGE_FIXTURES, one table the core unit spec and the SQL int-spec both consume. (6) Two count sets, two scopes, and getting it backwards is invisible until an operator clicks a chip: bucket counts havebucketremoved andstageapplied; stage counts havestageremoved andbucketapplied — andcountReturnsByStagestripsstageitself, so the rule survives a caller that forgets it.resolveScopedTotaldeliberately gains no stage arm: the bucket counts already carry the stage filter, so it resolves the stage-filtered total unchanged, whereas readingstageCountsthere would report the stage-less total on a stage-filtered page. Spec § 4.1’s six overlapping segments are a different, non-partitioning set and are out of scope here.ReturnStatusCell— which could render onlyDeclined, for want of counters, and whose own docblock named this issue as its successor — is replaced rather than supplemented. -
The worklist strip, the filter set and the two rails (#2378,
W2-41, spec § 4/§ 5): Wave 1c shipped the list and the detail shell; #2378 gives them the state they were built to show. Six properties are decisions. (1) Segments OVERLAP; stages PARTITION, and the two must not share a mechanism.ReturnStageCounts(#2377) is a partition whose int-spec assertsΣ byStage === total;ReturnSegmentCountsis the opposite shape — one return can beneeds_dispositionandmoney_pendingandorphansat once, andall_opendeliberately overlaps almost everything — so there is no sum assertion, the warning saying so lives on the TYPE rather than only in a plan (the sibling asserts the opposite one file over, and a reader copying it is the likely failure), and the int-spec pins the INEQUALITY.SEGMENT_PREDICATESis therefore independent booleans, never aCASEladder. (2) ONEsegmentURL param with six values — a deliberate deviation from spec § 4.3’s literal param list, because that list cannot express three of its own segments:Needs receivingspansawaiting_parcelorpartially_received,Needs dispositionspansreceived_awaiting_dispositionor a partially-received line with undisposed units, andMoney pendingmeanspendingorin_doubt, whilestageandmoneyare each single-valued.attentionandorphanare dropped as separate params — they ARE segments, and two spellings of one filter is drift by construction. (3)orphansis an ordinary segment predicate, NOT a translation ofbucket=orphan. An earlier design mapped it at the API boundary; composed with the strip rule that would be a self-scoping bug — the FE would sendbucket, leaving nosegmentto strip, so the orphans card would report the count of the scope it is already in. It reaches the SAME ruleReturnRecord.isOrphan()states through one sharedORPHAN_PREDICATESQL constant (consumed by the bucket count, the bucket filter arm and the segment alike), andbucketstays an independently-usable filter a segment click never writes. (4) The counts-scoping rule generalises: bucket counts havebucketremoved and every other filter applied; stage counts havestageremoved; segment counts havesegmentremoved — andcountReturnsBySegmentstrips it ITSELF, the defence that already survived a caller forgetting it once. The sentence is written generically because it has been incomplete twice. (5) The strip has SEVEN cards:All returnsclearssegmentand is the default (unfiltered), the six segments sit beside it withAll openamong them.All openis a FILTER, not a clear — rendering it as the clear card would label closed, fully-refunded returns as open work. Onlyrestock_blockedandorphansmay render red (#2100’s split);all_openis neutral precisely because it is the largest card on any active install, and tones are keyed offMetricCard’s own prop type so a local union cannot type-check against a tone the primitive does not render. (6) The two rails are the existing per-line chips, extended — not a parallel component:ReturnLineStateChip’s Wave-1ctrackedprop was built as this seam and flips to default-true, gaining rail labels, thein_doubtwarning with its do not refund again copy (OL crossed a boundary and does not know the outcome; a retry moves real money twice) and therefundedattribution.refundednever renders without an observation —triggerRefundwritestriggeredand onlyrecordRefundObservationwritesrefunded, somoneyState === 'refunded'IS the observation and nothing derives one from the other, pinned by atriggered-only fixture. The standing “these move independently” sentence ships as COPY, once, above the table.openedFrom/openedTofilteropenedAt, the SOURCE’s own instant — nevercreatedAt, which is OL’s ingestion clock; wiring the spec’s param to the existing arm would answer a question about the marketplace’s timeline with OL’s. The counters subquery gained the custody/money aggregates and aRESTOCK_BLOCKED_EXISTSwhose predicate isfindOutstandingRestockEventsForReturn’s verbatim — attestation FLIPS a blocked act’srestockStatetohandled_manuallyrather than stamping it, so anattestedByEventId IS NULLclause would be backwards and keep every settled block forever.joinCountersOncemakes that join idempotent, because four paths can need it on one builder and a secondscalias is a TypeORM error on the ordinary “click a card, then narrow by stage” request. No migration. -
The per-line receive and dispose flows (#2380,
W2-42, spec § 5.2/§ 5.3): the acts the custody model exists to record. Wave 1c shipped the counters and #2376 their write API; both were inert from the UI until this. Six properties are decisions. (1) Two of the issue’s own controls had no backend, and were built rather than faked — the standing rule that a control the backend cannot serve is dead code that type-checks.markReturnCustodyNotReturnedwas shipped, exported and never called (no service method, no route), and the detail read carried no way to name the restock destination at all. (2) The destination is NAMED by the backend, never derived in the browser. § 5.3 requires “Stock will be added in {connection name}”, and the name previously reached the client only on arestockBlockedresponse — i.e. after the write had already failed.IReturnCustodyService.getRestockTarget()answers from the SAME private resolver the dispose write uses, so reported === written-to structurally; a client-side pick overenabledCapabilitiescould not reproducelistCapabilityAdapters’ candidate ordering and would confidently name a connection the write never touches.ReturnRestockTarget’s three non-resolved arms reuse the vocabulary a blocked restock records verbatim (ambiguous-inventory-master/no-inventory-master/adapter-unresolved), because a disclosure naming its states differently from the block it predicts is a second, drifting answer to one question — andambiguous-inventory-masteris a blocked restock, not a pick, so the UI must never render a candidate there. (3)Mark as not returned, not “Mark remainder not returned”. The spec’s phrasing describes a shortfall write the shipped model refuses: custody is single-valued per line, a line holding received units still needs a disposition for them, and there is noquantityNotReturnedcounter. The control is gated onquantityReceived === 0so the refusal is never discovered by clicking; where units did arrive the shortfall stays visible asquantityAdvised - quantityReceived, which is what the spec was reaching for. (4) A guard ordered behind a broader one was dead, and replaced by a WRONG answer.partially-receivedsat after the state check, but receiving units is exactly what moves a line toreceived— so no reachable line could emit it, and a half-delivered parcel was refused as “already finished”. Its unit test passed because it hand-built{custodyState: 'in_transit', quantityReceived: 2}, a combination the receipt transition cannot produce; an int-spec driving the real routes found it. The guards are reordered, the fixture now runs the real transition, and the shape is recorded indocs/lessons.md. (5) A newReturnLineEventKindmember breaks nothing AND warns nobody — the union has noassertNeverswitch, no exhaustiveRecord, and reaches persistence through an unchecked cast, so consumers are found by grep.toLineEventKindconverts that silence into a warning while passing the stored value through verbatim (it is what the row says, not a lie); unlike thereadReturnReasonprecedent beside it,kindhas no honest default andReturnLineEvent.kindis non-nullable, so an unrecognised value necessarily arrives typed as a member of a union it is not in. That is tolerable only while nothing branches onkind; the firstswitchorRecordkeyed on it makes the cast load-bearing and the entity-type widening stops being optional. (6) The ≥44 px floor is explicit and width-keyed, not inherited: the global rule fires under@media (hover: none) and (pointer: coarse)— a POINTER test — while both the acceptance criterion and § 5.2’s declared style-guide departure frame the guarantee in terms of WIDTH, so a 768 px desktop window and every component test get nothing from it. A test strips media blocks by brace-counting and asserts the custody rules sit outside them. Also here: anothing-advisedrefusal so a zero-advised line answers 409 with a code rather than a rawCHK_return_line_events_quantity_positive500;SegmentedControlgains realdisabledsupport (arrow-nav skips it, the tab stop never parks on it) soRestockcan be shown-and-refused with its reason beside it rather than hidden; and the restock block is surfaced in place on the 2xx dispose response, because a stock write that silently no-ops is worse than none and § 5.4’s full treatment is a sibling issue. No migration. -
The order-detail returns panel, and the return-scoped activity timeline (#2640 / #2646, spec § 5 / § 5.4): two operator surfaces, and one backend arm each.
ReturnListFilter.internalOrderIdis an ordinary filter arm — deliberately NOT a translation ofbucket: 'attributed', which is the #2378orphansconflation one dimension over; it rides in the controller’s sharedscope, so all four count reads see it and the panel’s numbers describe the rows beneath them.findTimelineEntriesForReturnis the SAME query and the SAME projection as #2383’s order-scoped read, differing only in theWHERE— one sharedprojectTimelineRows, so the two timelines speak one vocabulary by construction rather than by agreement, and the header loop grows from two kinds to five (opened/authorized/declined/matched/closed), which the ORDER timeline gains too — of whichclosedis declared and written by nothing today (createtakes it and its one production caller passesnull;upsertFromSourceblanks it), emitted anyway because the column is real and ADR-060 makes the four timestamps independent. Five properties are load-bearing. (1) It is keyed on the RETURN, which is the whole reason it is a second read: an orphan has nointernalOrderIdat all, so the order-scoped read cannot serve it — and #2372’s own use case is an orphan being matched. (2) A header column that HAS an actor is attributed to that actor, and the FEresolveBykeys on the actor’s PRESENCE rather than on a kind allow-list: a list fails closed to the source claim, so the next actor-bearing column would credit an operator’s act to the channel — the confident false attributionREFUND_EXECUTED_BY_COPYexists to prevent.matchedis the only such kind today;openedanddeclinedstill carryactorUserId: nulland remain a source claim or nothing. (3) An empty projection is a 404, never an empty timeline — the join is a LEFT JOIN offreturns, so a real return with no acts still yields one row; reporting[]would render a history for a return that is not there. (4)GET /returns/:returnId/eventsis@Roles('admin','operator','viewer'), for the reason #2905 narrowed its order-scoped sibling: the entries carry a refundamountandcurrency, and apackercan obtain return ids from the@AnyRole()list. That does NOT close the wider hole —GET /returns/:returnIdis still@AnyRole()and already returnsrefunds[]with amounts — which is pre-existing and stated rather than implied fixed. (5) The panel lives infeatures/returns, notfeatures/orders, a deliberate deviation from #2640’s text:features/returnsalready value-imports the orders barrel (return-money-panel.tsx), so a panel infeatures/ordersre-exported from that barrel would close a runtime barrel-to-barrel cycle (#337/#359);pages/orders/order-detail-page.tsxalready imports from'../../returns', so mounting it there adds no new edge direction. Both surfaces distinguish loading, a FAILED read, an unreadable envelope and a confirmed empty, and only the last asserts anything about the operator’s data; the panel additionally discloses a truncated page with both numbers rather than trimming quietly. The § 5.4 restock-blocked badge reaches the panel by renderingReturnStageCell— the same component the list uses — which is one step stronger than importing the copy constant, since there is no second render to drift. Known limitation:ReturnListItem.restockBlockedis a per-RETURN boolean, so the panel can only say this return holds a blocked restock; per-LINE blocks live on the return detail, which this panel does not read. -
Deliberately NOT in this slice: no
return.decline(#2333), no read API (#2334), no restock execution, no HTTP mapping forReturnNotAttributedError(#2334 owns the filter; unmapped, it surfaces as a 500), no manual operator re-attribution, no operator-facing backfill of a seller’s pre-bootstrap return history, and no overlappedcreatedAt.gterepair re-sweep for SPIKE-2289 risk 2 (both named follow-ups).shipments.directionwas likewise out of that slice and is now actioned — see § 23 below.
23. Shipment direction
Section titled “23. Shipment direction”- A return label IS a shipment, so it shares the table — and the discriminator is what keeps every predicate honest (#2373, ADR-060): same carrier, same waybill, same tracking poll, so a parallel
return_shipmentstable would have duplicated every read predicate, the status machine and thewaybillRelayedAtclaim discipline.shipments.direction(outbound | return) is the column that lets one table hold both. directionwent into the branch-1 index’s KEY columns, never its WHERE clause.UQ_shipments_branch_one_per_order_connis nowUNIQUE (orderId, connectionId, direction) WHERE "providerShipmentId" IS NULL. Written instead asdirection = 'outbound'in the predicate it would have kept guarding outbound rows and stopped guarding return rows entirely — any number of branch-1 return rows per(order, connection). As a key column it is neither too narrow (it admits exactly the outbound+return pair ADR-060 needs) nor too wide (a second row in either direction is still refused). Verified against live Postgres, not inferred: an outbound and a return branch-1 row insert cleanly for one(order, connection), while a second outbound and a second return are both rejected onKey ("orderId", "connectionId", direction).- The column carries NO database default, and the migration’s temporary one is a statement about history rather than a placeholder. Migration
1862000000000adds itNOT NULL DEFAULT 'outbound'and drops the default in the sameup().'outbound'is true of every existing row:shipmentsrows are written only byShipmentDispatchService(an operator buying a label for goods going to a buyer) or byFulfillmentStatusSyncService’s branch-1 projection (a marketplace reporting the seller shipped), and no code path in the tree has ever been able to create a return label. Dropping the default is what stops it becoming an implicit answer for future inserts — left in place, TypeORM’s schema sync would keep re-adding it anddirectionwould be unstated-but-present. The ORM entity declares no default either, so the migration andsynchronize(which the integration harness uses instead of migrations) build the identical shape. - Three read predicates take a REQUIRED
direction; the fourth takes an optional filter, and the asymmetry is the point.findByOrderId/findActiveByOrderId/findBranchOneByOrderAndConnectionrequire it because a default would be a silent decline — a later call site would read the outbound cohort while believing it read every shipment, and nothing would say so; required, omission is a compile error.findManyinstead gains an optionalShipmentFilters.direction, because it also backs the operator-facingGET /shipmentslist, where a hard outbound filter would make return labels permanently invisible with no signal. The status-sync scan therefore passes'outbound'explicitly rather than inheriting it, and the list API exposesdirectionas both a filter and a response field so the operator sees the cohort instead of having it chosen for them.findBranchOneByOrderAndConnectionadditionally must carry it, or the lookup would match a sibling row the widened index deliberately permits. - Nothing writes
'return'yet. Buying a return label is a later slice; this one gives it a column and an index that can hold it, exercised by the D8 coexistence regression test. The rollback is consequently safe today and conditionally destructive later — recreating the two-column index fails on any order holding both cohorts — which is recorded in the migration’s owndown().
24. Automation
Section titled “24. Automation”See docs/specs/product-spec-oms-wave2-operator-experience.md §5 for the design of record.
- Responsibility: operator-authored automation v1 — a closed set of 8 triggers × 6 actions over a declared legality matrix. “When X happens, and only if …, then do Y (and Z).”
- Location:
libs/core/src/automation/(#2358). Reached at@openlinker/core/automation; likereturns, a new context stays OFF the aggregating root barrel. - Not a zero-sibling-edge leaf, unlike
sales-documents/order-lifecycle: it carries a module, repositories and ORM entities, plus exactly one deliberate sibling VALUE edge —HoldReasonfrom@openlinker/core/order-lifecycle, which is itself a leaf and therefore cannot close a CJS module-load cycle. Restating the eight hold-reason strings locally is precisely the drift that leaf exists to prevent, and spec §5.3b is explicit that the composer cannot add a reason. - Storage mirrors the shipped #2161/#2170
sales_document_rulesengine rather than inventing a second answer to “how is a rule stored and evaluated”: scope in real columns, kind-specific data injsonb,effectiveFrom/effectiveTo, an active flag, AND-only closed-vocabulary conditions, and a canonicalize+hash duplicate guard whose malformed rows never match rather than throwing. Three divergences are declared (spec §5.5) and restated on theAutomationRuleentity so a later reader sees them as intentional: scoped by trigger, not country (the operator’s model is “when X happens, do Y”; a country index would be a category error);orderTotalGrosscarries an inline amount + currency rather than #2161’sthresholdRef(that indirection existed so a legal amount could version independently of the rules citing it, a concern automation does not have — and since #3189 this is no longer a divergence at all, the sales-document rules having inlined their amounts too for a different reason: the indirection made the amount unauthorable. Currency mismatch still resolves the #2161 way: no conversion, ever, the rule simply does not match); and actions are an ordered multi-step list capped at 3, stop-on-first-failure, where #2161 has one outcome. Two further divergences follow from the third: one combineddefinitionHashover(trigger, triggerConfig, conditions, actions)because the action list is part of rule identity, and — like #2170 — noprioritycolumn, since a silent tie-break on an action that spends money is what the #2047 lineage exists to prevent. - Several rules may fire, EXCEPT for irreversible actions. Automation is asymmetric where invoicing is not: two emails are recoverable, two labels are not. Reversible actions (A3–A6) all fire; for A1
issue-sales-documentand A2dispatch-shipmentthe #2047 rule applies verbatim — two matching rules resolveblocked, nothing fires, and the run row names both colliding rules viaautomation_runs.blockedByRuleIds(a singleruleIdcannot, and spec §5.6 requires the row to say which rules collided).AUTOMATION_ACTION_IS_IRREVERSIBLEdeclares the split once, as a property of the action; the runtime gate (#2362) reads it rather than restating it. - The gate is a COMPOSITION, and four of its properties are load-bearing (#2362).
AUTOMATION_DISPATCH_SERVICE_TOKENnow resolves toAutomationIrreversibleGateService, which partitions the matched set via the puregateIrreversibleAutomationActionsand hands the survivors toAutomationDispatchService— the third provider-binding swap on a seam #2360 declared inert, and the reason #2361’s step ordering, stop-on-failure and run recording are untouched. (1) Collision is keyed PER IRREVERSIBLE ACTION KIND, never “any two irreversible rules”. ADR-041’s invariant is at most one originating document, not at most one irreversible act: issuing a fiscal document and buying a label touch different resources and neither duplicates the other, so an A1 rule beside an A2 rule is a configuration an operator may legitimately author, and blocking it would make the mirror stricter than the gate it enforces (the #2240 failure). (2) The partition is ONE pass over the ORIGINAL matched set, with no cascade — freeing an A2 candidate because its rival was already blocked on A1 derives a winner FROM a block, which is silence-and-pick-one through the back door and is exactly what ADR-041 §6 forbids; blocking is per RULE rather than per step, since a rule’s steps cannot half-run. (3) A block is reported through the SAME single recorder seam every other outcome uses, so #2385’sautomation_runswrite path makesblockedpersisted and operator-visible with no second write path that could disagree (§5.6’s “one record, four readings”); it is best-effort and never throws, so a reporting failure cannot cost the unblocked rules their dispatch. (4) The firing claim a blocked sweep-triggered rule consumed is NOT released. The emitter takes it before dispatch (#2360), and the collision is a configuration fact that recurs identically every tick — retrying would write oneblockedrun per rule per tick and drown the very log the AF-X state reads, while releasing needs a durable delete that reopens the re-fire window the claim exists to close. It matches #2358’s own decision that editing a rule does not erase its firing record; the operator-facing consequence is that deactivating the losing rule does not re-arm it, so the fix must be followed by a manual trigger. ConsequentlyAutomationEmissionResult.firedRuleIdsmeans handed to dispatch, never fired —dispatch()staysPromise<void>so the verdict cannot leak back into the emitter, and the run record is the authority. The blocked path is unreachable in practice today (A1/A2 both resolve toUnavailableActionExecutorService, #2361) and is built and tested anyway, because the gate reads a rule’s declaredactionsrather than executor availability and therefore arms the moment A1/A2 land. - Three tables, and the third is not redundant.
automation_rules,automation_runs(one row per firing — the single record behind all four §5.6 history renderings, so a firing cannot show as succeeded in one place and failed in another), andautomation_trigger_firings— the durable at-most-once record for the twodeadline-sweeptriggers (T3 “on hold for N”, T4 “dispatch deadline near”). It is a separate table because the retention policies are incompatible: runs are kept 90 days while the sweep guarantee is “at most once per (rule, order), ever”, so a quarterly-pruned table would re-fire every handled pair on day 91 — on a T4→A2 rule, a second label bought with real money. Its unique key deliberately excludesdefinitionHash(“editing a rule does not erase its firing record”); adding it would silently re-arm every T3/T4 rule against its whole backlog on the next edit. #2360’s writer is anON CONFLICT DO NOTHINGagainst that index — a durable conditional write, never a best-effort in-memory guard. - Nothing here carries an FK. Not runs/firings →
automation_rules(a deleted rule must neither destroy its history nor be blocked by it;ruleNameandtriggerare frozen onto the run row at write time — the #2282 attribution-freeze pattern — so an orphaned run still renders, and consumers link byruleIdwhile displayingruleName), and notsubjectId→order_records(theorder_changes/refund_recordsprecedent of an indexed reference by value). The integration harness therefore truncates all three explicitly. - Two schema choices that read as omissions and are not. No PG enum or
CHECKon any vocabulary column, and noCHECKon the 1..3 action-list cap: the integration harness builds schema by TypeORMsynchronize, which emits neither, so a constraint present only in the migration would hold in production and silently not in tests.is*narrowers coerce on read and the cap is a service invariant. AndisActivedefaults false — fail closed — though the real enforcement point is the service, since a column default only fires when the column is omitted from the INSERT. createdAton a rule is behavioural, not an audit stamp: spec §5.2’s S3-9 (“a rule created today acts only on facts that occur after it was saved”) is implemented by the deadline sweep comparing against it. A rule created against a backlog of 40 held orders must fire nothing.- The evaluator is PURE, and every non-firing exit is named (#2359).
evaluateAutomationRules(domain/domain-services/, beside theevaluateSalesDocumentRulesprecedent) takes the trigger that fired, an already-assembledAutomationSubjectFactsprojection, the caller-loaded rules and an explicitnow— no I/O, no injected dependency, no clock, no argument mutation, asserted by a spec that scans its own source. Purity is not stylistic: an evaluator that touches I/O cannot back the §5.6(a) dry run, which is the gate an operator passes before arming a rule that spends money. Four properties are load-bearing. (1) Nothing short-circuits, and the trace survives an early rejection — unlike #2170, which stops at the first false condition because it only needs a verdict, this returns a per-condition trace whether or not the rule matched, because that trace IS the dry run’s table and an operator who fixes condition one should not then discover condition three. It is built for every rule SCOPED to the fired trigger, including one already ruled out for another reason (switched off, window closed, retroactivity floor); only a rule about a DIFFERENT event (trigger-mismatch/unknown-trigger) gets an empty trace, because its conditions were never asked. Withholding it otherwise would leave the operator’s own test rendering nothing, which is how the §5.6(a) gate stops being a gate. (2) Every rule comes back with an evaluation, and a non-matching one carries a closedAutomationNonFiringReason(theSalesDocumentBlockOutcomeprecedent, ADR-041 §54): returning a bare list of matches would make “your rule is inactive”, “its window closed”, “a condition was unknown” and “it does not apply to this trigger” the same observation — nothing. (3) An unknown fact never collapses into a known one. EveryAutomationSubjectFactsfield is optional and absence means unknown, never false/empty/“no hold” — the same widening ADR-063 made tobuyerHasTaxId. A condition over an unasserted fact readsunknown, distinct fromfalse, so the dry run says “we could not tell” rather than “it did not match”. The retroactivity floor obeys the same rule: an unknownoccurredAtreportsfact-time-unknownand fires nothing, because the wrong guess buys 40 labels. The floor is a FIRING rule, not an evaluation rule, soenforceRetroactivityFloorcan waive it — defaulting totrueso an omission can never widen what fires, with the §5.6(a) dry run its only intended caller (a preview run against an order from the last 30 days would otherwise reportfact-precedes-rulefor every test and render nothing). A waiver is REPORTED per evaluation rather than merely applied: a preview that silently differs from what would really fire is the shape of a bad surprise. (4) The at-most-one gate is NOT here — several rules matching is a normal, correct result; §5.5 divergence 3 places that decision at runtime in #2362’s dispatcher, over this function’smatchedlist. - The §5.4 legality matrix is ONE declared table with three consumers (#2359): the write path (
AutomationRulesService, so an illegal pair cannot be persisted by curl either — #2363’s API validation is defence in depth, not the only line), the composer’s option list, and the evaluator’s own guard, where a row that predates the table reportsillegal-trigger-action-pairrather than throwing.satisfieson the map makes a ninth trigger or seventh action a compile error rather than a pair that silently reads as illegal — which would itself be a rule that saves and never fires, the exact defect the table prevents one level up. A second, narrower table records §5.5’s statement thatholdReasonis offered only for T1/T2/T3, and is deliberately declared-only: such a condition on anorder.packedrule already resolves correctly through the ordinary path (the facts assert no hold reason, so it readsunknownand the trace shows which condition), and a separate whole-rule refusal would replace an explanation with a bare rejection. That runtime posture is only acceptable because both tables are enforced on the write path, where there is an operator to tell — an unenforced condition-field table would let aholdReasoncondition on anorder.packedrule persist via curl and then never fire, the exact defect the tables exist to prevent. - #2358 lands storage only. Named siblings own the rest: #2360 trigger emission (and the firings writer), #2361 the six action executors, #2363 the CRUD/evaluate/fired-log API, #2385 the
automation_runswrite path and the per-step outcome shape (thesync_jobslink for a step that dispatched a job rides inside thatstepsjsonb, not in a column), #2387 the AF-X attention state. The §5.6 90-day run-retention sweep is named in the spec and owned by no issue yet;DemoAccountCleanupServiceis the repo’s one Postgres row-deletion retention precedent to mirror.
25. Operational Settings
Section titled “25. Operational Settings”See ADR-069 for the decision rationale.
- Responsibility: hold the knobs that decide how hard OpenLinker works a shop’s catalogue, and let an operator reach them (#2651). Before this, the catalogue sweep budget, the inventory sweep budget, the sweep page size and the deletion-audit budget and cadence were code constants with env-var overrides, so changing one meant editing a
.envand restarting the worker - and since #2279 moved the scheduler into the worker,OL_MASTER_PRODUCT_RECONCILE_CRONset on the api was silently ignored, which is a second reason the env-var route is a poor operator surface. - Location:
libs/core/src/operational-settings/- a leaf context: nothing under it imports a sibling@openlinker/core/*context and nothing under it speaks HTTP. Interface:GET/PUT /operational-settings(apps/api/src/operational-settings/,@Roles('admin')). Storage: the singletonoperational_settingsrow, the third instance of the shapeai_provider_active_setting(#451/#452) andreporting_currency_setting(ADR-040) established. - The env rung answers for THIS process, and the answer says so (#2660 review).
OperationalSettingsServiceruns in both the api and the worker, which load their own.envand carry separateenvironment:blocks indocker-compose.yml- and since #2279 the sweeps run in the WORKER. So an env rung resolved in the api describes the api, never the process that applies the value; an operator settingOL_PRODUCT_SYNC_PAGE_LIMITinapps/api/.env(the file whose.env.examplethey read) saw the settings page report it while the worker kept the default for ever - reported ≠ enforced, on the exact axis #2651 exists to close. Every resolved value therefore carriesworkerMayDiffer(source !== 'setting', since only the shared ROW is authoritative for both processes), the page states it once above the panels, and both.env.examplefiles now say which process reads each variable. The rung is reported rather than dropped, because dropping it would answerdefaultfor a value an env var really did change. The two sweep CADENCES are reported for the same reason:OL_PRODUCT_SYNC_CRON/OL_INVENTORY_SYNC_CRONare settable andapps/api/.env.exampleships one uncommented, so a calculator assuming the shipped 20 / 15 minutes reports a pass length an order of magnitude wrong on an install that changed one. They are read-only on this surface (no column, no input) andCORE_CAPABILITY_TASKSconsumes the same constants as itsdefaultCron, so what is reported and what the scheduler registers cannot drift. - Resolution is
DB row -> env var -> code default, and every value is reported with the rung that produced it (OperationalSettingsView,source: setting | env | default- theReportingCurrencySettingsView.sourceshape). Every column is nullable andNULLmeans “not set”, so an install that upgrades past the migration and never opens the settings page behaves byte-identically to how it behaved before, and an install that had only ever set an env var keeps getting that value. That is what makes the change safe to deploy rather than a behaviour change dressed as a settings page. Reporting the rung is what lets a UI render500 (default)instead of comparing against a hardcoded number client-side - a client-side comparison is a second copy of the default, and it is wrong the day the default moves. - The worker reads the setting PER TICK, not at boot. Both sweep handlers and the deletion-audit handler call
IOperationalSettingsService.resolve()insideexecute(), so a budget change reaches a running worker with no restart - a value that needed one would be barely better than the env var it replaces. The cost is one singleton primary-key lookup against a run that is about to issue platform calls, which is the same reasonMultiProviderAiCompletionAdapterreads its active provider through the database on every completion. The property is proven by a test rather than asserted: a spec changes the budget between twoexecute()calls on the SAME handler instance and asserts the second run enqueued more children. - The cadence is the honest exception, and says so. A
CronJob’s expression is fixed at construction, soSchedulerServicesnapshots the resolved cadences inrefreshOperationalSettings()- awaited bySchedulerLeaseCoordinatorimmediately beforestart()- and a cadence change applies at the next scheduler start (a lease hand-over or a restart). The response statescadenceAppliesAt: 'next-scheduler-start'rather than implying otherwise. That refresh never throws: a database hiccup while acquiring the lease must not stop the crons registering, since a fleet with no scheduler at all is strictly worse than one running on the previous cadence, so a failure leaves every task on itscronEnvVar/defaultCronresolution. Onlymaster-product-reconciledeclares acadenceFromSettings; widening the surface to every task is a separate decision. - Two kinds of ceiling, deliberately not conflated.
OPERATIONAL_SETTING_BOUNDScarries arecommendedMax(advisory - OUR judgement, exceedable) and anabsoluteMax(refused whatever the request says), each with a reason string the UI renders instead of inventing one. A write above the recommendation needsacknowledgeAboveRecommended: trueon the request, so a mistyped number is still refused while a deliberate one is honoured; the flag is per-request and is stripped before persistence, because one decision to run past our advice must not silently license every later write. The table is read by the API validator, by the response’sboundsblock (a form derives its limits rather than restating them) and by the worker’s own clamp - reported === enforced structurally, the #2229 rule applied to a settings surface. The resolve-time clamp usesabsoluteMax, neverrecommendedMax: narrowing an acknowledged value back down would show 5000 on the settings page and run 2000 in the sweep, which is the reported-versus-enforced gap in its purest form. A DTO decorator is a convenience for the OpenAPI document and a fast rejection, never the gate (the browser is not a trust boundary and the raw JSON path bypasses any form), and its@Maxis therefore the absolute ceiling - enforcing the recommendation there would refuse a legitimate acknowledged request before the service could weigh it. - A platform’s own wall is enforced where the request is built, and it REFUSES rather than narrowing. The first revision of this table capped
sweepPageSizeat 100 on the stated grounds that it was “PrestaShop’s collection page size and WooCommerce’s hardper_pagecap”. Both halves were wrong for that value. PrestaShop pages through thelimit=[offset,]countcomma syntax with no cap (prestashop-query.builder.ts; #2644 measuredlimit=99000,500against a live shop), so 100 was an OpenLinker convention presented as a platform fact - the kind of claim that quietly makes our opinion binding on somebody else’s hardware. And WooCommerce’sper_pagecap, which IS real, does not apply to that value at all: it is the batch child’sgroupSize, never aper_page. The genuineper_pagecarrier is the enumeration page size, andWooCommerceProductMasterAdapterrefuses an enumeration above it (exceedsAdapterPageSize) rather than clamping - a refusal being the only safe answer THERE, becausereadPagedIdsinfers end-of-collection from a page shorter than the size it requested, so a clamped page reads as the end of the catalogue: a cycle “completed” after 100 products, the cursor cleared, and the same first 100 were swept on every tick for ever behind one warn line (#2660 review). Before the clamp existed WP REST answered 400 and the job failed loudly, which is strictly better; a page size an operator set is never quietly not what was sent, and never silently truncates a sweep either.sweepPageSize’s absolute ceiling of 500 is a real mechanism rather than a round number: the batch child’s ids are|-joined into a query-string filter, so 500 seven-digit ids is roughly 4 KB of query string, comfortably under the 8 KB request-line limit nginx and Apache default to, and 2000 would not be. - The deletion audit cannot be disabled here. #2222 made
master.product.reconcilethe deletion authority, so switching it off silently reopens #1689 - a deleted product whose offers keep selling.OperationalSettingsInputtherefore carries a cadence and no enablement, the view reportsdeletionAuditAlwaysEnabled: trueso the absence reads as a decision rather than an oversight, and a cadence firing less often than weekly is refused as a disable in disguise (0 0 1 1 *is a valid cron expression and an effectively dead audit). The check probes by TIME, not by occurrence (#2660 review): measuring the gap between the next TWO firings accepted0 0 1,2 1 *- two firings one day apart, then nothing for a year - so the audit ran twice a year while the gate reported it closed. Walking N consecutive firings cannot be made sound at any fixed N either, because a day-of-month list fires 28 times in January and then not for eleven months, so the offending gap always sits just past N. Stepping forward one maximum-gap at a time across a year and asking “is there a firing within the next 7 days from HERE” tests the actual property at a cost independent of how densely the expression fires.OL_MASTER_PRODUCT_RECONCILE_ENABLEDremains the only off switch. A stored cadence is also re-validated on the way OUT, falling back rather than handingSchedulerServicean expression that makesnew CronJobthrow - that aborts the whole registration loop and leaves the fleet with no scheduled tasks at all. - The two per-sweep batch-size env vars survive as narrower overrides.
OL_PRODUCT_SYNC_BATCH_SIZEandOL_INVENTORY_SYNC_BATCH_SIZEare consulted only while no operator has set the shared page size (sweepPageSize.source !== 'setting'). Collapsing them into the one setting would silently change the inventory sweep’s page size for an install that had tuned only the product one - a behaviour change on upgrade, which is what the fall-through design exists to avoid. - Explicitly out of scope: the outbound request rate limit.
Connection.config.rateLimitis per-connection, already validated byConnectionService.validateRateLimitConfigand already edited byRateLimitSectionon the connection form (#1810). Adding it here would give one value two homes. syncJobRetentionDays/syncJobDeadRetentionDays(#2946, D16) joined the same genericOPERATIONAL_SETTING_KEYSnumeric-knob shape this section describes — bounded 30-365 days, no separate advisory ceiling (recommendedMax === absoluteMaxfor both, since D16 gives one hard bound rather than the soft/hard pair every other knob here has), defaults 30 / 90. The consuming sweep and its design are described under § Sync Manager rather than here, to avoid two descriptions of one mechanism drifting.
26. Fulfillment
Section titled “26. Fulfillment”See ADR-054 for FulfillmentWork as the unit of assignment and ADR-053 for the leaf posture.
-
Responsibility: own the vocabulary of fulfilment assignment —
FulfillmentWork(an order’s line-quantities grouped per location and delivery method), its counter-bearing lines, the ref an executor is handed, and the closed action vocabulary the read model computes legality over. ADR-053 additionally places the A2 (sourcing) and A3 (fulfillment-execution) authority resolutions in this context, since it owns those writes; neither exists yet. -
Location:
libs/core/src/fulfillment/. The fourth dependency-free vocabulary leaf, holding the same zero-sibling-edge property as § 17 / § 20 / § 21 and pinned by the same spec. -
Splits exist only at this grain (#2391, ADR-054). OL cannot split a commercial order —
identifier_mappingsis a bijection per connection (ADR-044), so a split child is permanently unmappable on its origin and a marketplace cancel would leave it live and shippable. So the WORK is split and the order is left alone: the Shopify FulfillmentOrder answer, adopted as the only available one. -
Two orthogonal state axes, never merged.
FulfillmentWorkStatus(execution:open…closed/cancelled/incomplete) ×FulfillmentRequestStatus(negotiation:unsubmitted…accepted/rejected/cancellation_*). Collapsing them yields the “cancel is a command” bug — cancelling work a holder has ALREADY ACCEPTED is a negotiation that holder may refuse, and a merged axis has no way to say “we asked and they said no” without lying about one of the two questions. The split mirrors ADR-007’s status-vs-outcome rule one layer up. -
Lines carry COUNTERS, never per-line statuses:
totalQuantity/fulfilledQuantity/cancelledQuantity, because “3 of 5 shipped” is not a status. The invariantfulfilled + cancelled <= totalships here as the purecheckFulfillmentWorkLineCapacityand becomes the DBCHECKin #2392 — the rule exists once, before the column does, and the two move together. -
supportedActionsis deliberately NOT a field on the aggregate. DESIGN §5.2 computes it server-side on the read model, actionable only with an optimistic-concurrency token (a stale action answers 409 with a refreshed set) — that is #2406’s. A field here would invite a client to recompute legality locally, which is the state-machine drift across heterogeneous executors the design exists to kill. This context ships only the closed action vocabulary that computation ranges over, and one of the seven execution states is reachable by no action at all —incomplete, entered by ashort_pickedprogress event;on_holdis reached only indirectly, via theholdaction’s hold-row write. Both are stated per-member rather than left to be discovered. -
The ADR-053 no-injection invariant is binding on everything under the directory: this context injects no
orders/inventoryservice. Order data enters as ARGUMENTS — which is whyFulfillmentWork.orderIdis a plain internal-id string rather than anOrder— and a type need goes through@openlinker/core/orders/types, which that guard permits by exact-specifier match. Note it is deliberately NOT in this leaf’sZERO_SIBLING_EDGE_LEAVESallow-set, so taking that route additionally requires a one-line registration there — per-leaf and deliberate, never a free ride. Enforced as a prohibition byscripts/check-no-injection-contracts.mjs, whose source-text scan cannot seeModuleRef.get(TOKEN, { strict: false })(an idiomInvoiceServicealready uses); the complement isapps/worker/test/integration/fulfillment-no-injection-boot.int-spec.ts, which asserts today that no Nest decorator exists under the context at all and arms the provider-graph assertion the moment one does. -
One authorized carve-out:
FulfillmentCancellationReason, imported type-only from@openlinker/core/fulfillment-authority(#2304). It is the first main@openlinker/core/<ctx>barrel in the allow-set table, admissible only because the import erases at build time AND the target is itself a registered zero-sibling-edge leaf exporting no NestJS module — restating the union locally is the duplication ADR-053 § Alternatives rejects by name. A SECOND carve-out landed with #2392:HoldReason, type-only from@openlinker/core/order-lifecycle(#2305), forFulfillmentHold.reason— design adjudication #4 keeps that one vocabulary across both hold grains, so restating it is the same duplication ADR-053 rejects. Each specifier is registered on its own merits, never as a widening of the other, and the authorization is conditional onorder-lifecyclecontinuing to export no NestJS module. Note the leaf can borrow the TYPE but not theisHoldReasonGUARD: a guard is a value import, which the walker forbids from a registered leaf unconditionally (automationnarrows this same union precisely because it is not a leaf), so the repository casts that one column at the boundary — theReturnLine.custodyStateprecedent — rather than narrowing it. -
Persisted since #2392 (ADR-054): three tables —
fulfillment_works,fulfillment_work_linesandfulfillment_holds— plusFulfillmentWorkRepositoryPort. Five properties are load-bearing. (1) There is nosave(work). REVIEW C10 callsfulfillment_worksa five-writer table (router #2395, executor handshake #2399, progress ingress #2400, operator actions #2406/#2410, sweeps), so a full-row save would reproduce theorder_recordsmulti-writer problem at a hotter grain. Every mutation is a narrow conditional UPDATE answeringaffected > 0, and each column names its sole writer in a table in the repository’s file header; five columns are insert-only — a stronger statement thantoOrmexclusion, since they are settable at create (ADR-054 R1 creates work ALREADY ASSIGNED) and immutable afterwards.createtakes an opaque caller-owned transaction handle (deliberately not anEntityManager—engineering-standards.md § Domain Layer Independenceforbids the domain layer naming framework code) so #2395 can commit N work rows and the order’s terminalisation in one transaction. (2)falsemeans “not applied”, not “error” — two writers racing is the steady state; onlyreleaseHoldthrows, because its two zero-row causes (unknown hold vs benign double-release) are different facts an operator must tell apart. (3)CHK_fulfillment_work_lines_capacityand the purecheckFulfillmentWorkLineCapacityare ONE rule. #2392 widened the function with non-negativity clauses so the two agree on every input rather than only on the happy path — as first shipped,{total: 5, fulfilled: -1}passed the function and would have failed the constraint, falsifying the function’s own “the two must move together” docblock. The@Checkis declared class-level under the migration’s exact name, because the integration harness builds schema bysynchronizeand an anonymous one carries a hash name there. (4) The ≤10 active-hold cap is repository-level, not a DB constraint, and it needs aFOR UPDATElock on the PARENT work row. Counting inside the transaction enforces nothing at READ COMMITTED: two concurrentplaceHoldcalls each see nine, both insert, and the work carries eleven active holds silently with no error. A plainSELECTtakes no locks, and the offending rows are a phantom — soFOR UPDATEonfulfillment_holdscannot help and only the parent row serialises count-then-insert. A partial unique index expresses N=1 only, and the sole construct that could express N>1 is a trigger, whichsynchronizedoes not emit — it would hold in production and silently not in tests. (5) FKs are per relation:fulfillment_work_linesandfulfillment_holdstake realON DELETE CASCADEforeign keys to their parent (migration-only, no@ManyToOne— a line is a part of its work), whileorderId,productVariantIdandassignedConnectionIdare indexed references by value with none, so a deleted connection cannot cascade away fulfilment history andorder_records’ lines-in-jsonb shape is respected. Because thesynchronize-built test schema has no FKs at all, CASCADE cannot be exercised by an ordinary int-spec — so #2392 also shipsfulfillment-work-migration-parity.int-spec.ts, the only automated check of a migration anywhere in this repository. It builds the migration’s schema in a second database (migrationsRun: true) and diffs the three tables against thesynchronize-built ones — columns, types, nullability, defaults, index definitions and CHECK predicates — then deletes a work and asserts its lines and holds go with it. Until then nothing in the tree had ever compared the two schemas, and the claim repeated across five docblocks that constraints are “declared under the SAME NAME” was unverified repo-wide. It earned itself on its first green run by finding that the migration named the primary keys whilesynchronizeminted hash names. A context adding tables should copy it rather than rediscover why it exists. -
Driving the negotiation: the handshake,
assignmentAttemptandfulfillment.work.dispatch(#2399, DESIGN §5.4, ADR-054):FulfillmentHandshakeServiceoffers a routed work object to its assigned holder and records the answer, drivingunsubmitted | rejected -> submitted -> accepted | rejectedandaccepted -> cancellation_requested -> cancellation_accepted | cancellation_rejected. Seven properties are load-bearing. (1) The counter is a persisted column bumped only by a router-driven RE-REQUEST, never the job-runner attempt - which changes on exactly the retrieswork:{workId}:{assignmentAttempt}must survive, so a re-minted key is a second fulfilment request to a 3PL, i.e. a double-ship (REVIEW C7; the Amazon MCFsellerFulfillmentOrderIdmodel).claimDispatchAttemptmoves the status and increments the counter in ONE conditional UPDATE and hands the attempt back through that statement’sRETURNING, so minting a key without the row already holding that value is not expressible - structurally stronger than asserting call order in a spec. It replaces #2392’sincrementAssignmentAttempt, whoseWHEREwas"id" = :idalone: with no state guard any caller could bump the counter out from under a livesubmitteddispatch and invalidate an in-flight key. It had no production callers. (2) What concurrency buys is stated precisely rather than overclaimed. The retry path RESUMES the claimed attempt and re-issues the call, so two concurrent runs can both reachrequestFulfillment; the row guard is therefore not what prevents a second outbound call - the port’s replay guarantee is. The invariant is exactly one claim, exactly one distinct idempotency key, at most one increment, and an integration test proves it against real Postgres with two overlapping claims. A sequential test passes against an implementation with no guard at all, so it is not weaker evidence here, it is none. (3) A resume is ATTEMPT-SCOPED. The payload carries the attempt the job was enqueued for; a delayed duplicate for attempt 1 waking after a re-request bumped the work to attempt 2 sends nothing, because resuming would mint a key it never claimed, for a holder it was not enqueued against.nullmeans “first dispatch”, and is also what a job queued across the deploy carries. (4) Acceptance is an at-most-once CLAIM -WHERE "requestStatus" = 'submitted' AND "acceptedAt" IS NULL, ADR-054’s own rule; the second conjunct is the one that still holds if a future writer movesrequestStatuswithout coming throughrecordAcceptance.acceptedAtstaysnullwhen the holder reports no instant, so at-most-once comes from the conditional UPDATE and never from the column being populated.externalWorkIdis stamped alongside it, admitted deliberately off theacceptedarm’s allowlist rather than by spread; #2400 reads it to correlate progress. (5) Rejections are an append-only TABLE, not the two columns #2392 deferred.blockingexists so re-sourcing can EXCLUDE the rejecter, and an exclusion is a set: A refuses blocking, the router tries B, B refuses too. A scalar pair holds only the LAST refusal, so A’s exclusion is silently lost and the loop the field exists to terminate runs anyway - the very defect, one level out.fulfillment_work_rejectionscarriesconnectionId(a refusal that does not say WHO excludes nobody, and it is captured at refusal time rather than read back fromassignedConnectionId, whichclearHoldermay since have moved) and a denormalisedorderId, which is a deliberate refusal to bind #2395: if re-sourcing mints a NEW work row, aworkId-keyed read returns[]and the exclusion is lost, so the lineage is carried and the broader read stays one line of SQL rather than a migration. The shipped read isworkId-keyed. Constraint recorded against #2395: if re-sourcing mints a new work row it must carry the blocking exclusions forward. The reject stamp is the guarded transition and the INSERT in ONE transaction - a lost guard inserts zero rows, pinned separately from durability because rollback and durability are different assertions. (6) The cancellation key lives in its own namespace,work:{workId}:{attempt}:cancel. Sharing the dispatch key would have the executor answer the cancellation with the dispatch’s cachedaccepted- the replay guarantee working exactly as specified - and OL would recordcancellation_acceptedfor a cancellation the holder never saw. (7) The executor and the ship-to arrive as ARGUMENTS. This context is a registered zero-sibling-edge leaf, so the service may not injectIIntegrationsService(a VALUE import from a sibling) nor anyordersservice (ADR-053’s no-injection invariant); both resolve inapps/worker, which already composes them. That is ADR-053’s own “order data enters as arguments” applied to a second kind of dependency - no guard weakened and no allow-list entry spent - and it has the useful consequence that the service unit-tests against a hand-rolled port with no Nest module at all.deliveryMethodis deliberately NOT an argument: it is an insert-only column the router owns, and a second source could disagree with the row. The job isfulfillment.work.dispatchin therealtimelane (ADR-050 picks by cost of starvation: this is the outbound “tell the holder to ship” for a just-routed order, where lateness costs a shipment - themarketplace.order.syncclass, not a paced sweep; #2594’s split-by-trigger has nothing to separate, so the type stays single), withSyncJob.connectionIdset to the work’s ownassignedConnectionIdand never a synthetic id, #2609’s defect being precisely a shared scope collapsing per-scope lane accounting installation-wide. Outcomes follow ADR-007: an accept or a no-op isok, a holder rejection is a terminalbusiness_failure(a deterministic business answer, already durable - retrying burns the ladder to be told the same thing), while unassigned work, an unreadable order snapshot, a missing shipping address and an unresolvable executor all throw and retry, because this slice does not own the enqueue and a terminal answer would dead-end permanently on work that becomes assignable a moment later. The handler reads the PII flag withgetEnvBoolean('OL_STORE_PII', true)and notgetPiiConfig(), which throws wheneverOL_PII_HASH_SALTis unset regardless of the flag and would break dispatch on every ordinary deployment; the hashed arm’slocationHashis honestlynull, because the ingestion-timecustomer_address_projections.addressHashstill has no read surface (#2395 owns adding one) and hashing the already-redacted snapshot address would yield one value per country. Reachable versus declared-only: the handshake, the counter, the schema and the job are complete and test-exercised, and since #2409 they are reachable end to end -openlinker.oms.v1advertisesFulfillmentExecutoralongsideOlFulfillmentExecutorAdapter, so A3 is assignable in the UI rather than only through a directPATCH /connections/:id, andfulfillment.work.dispatchresolves a real executor instead of raising its retryable “could not resolve” error. It stays inert until an operator both enables the OMS (ADR-055’s never-seeded rule) and enables the capability on that connection. Deferred with owners: the re-source loop that consumes the exclusion is #2395; a cancellation JOB type has no caller yet and would cost a lane registration plus a boot-coverage entry; and ADR-054’s timeout-as-rejection sweep - the one #2399 named and left, whichIDX_fulfillment_works_request_statuswas created for - shipped as #2712 and is described in the next bullet. -
A holder that never answers is reaped, and the reap is NON-BLOCKING (#2712, ADR-054):
fulfillment.work.timeoutSweepconverts asubmittedwork whose holder never answered intorejected, which is the re-routable state (FulfillmentHandshakeService’sCLAIMABLE_FROMalready contains it). The pre-#2712 behaviour was safe rather than merely absent - a stalled work was never silently retried, because the resume path is attempt-scoped - but nothing reaped it, so the work sat insubmittedfor ever and re-sourcing could never begin. Six properties are load-bearing. (1) A timeout rejection is neverblocking, and that is the decision the whole slice turns on.blockingexcludes the rejecter from re-sourcing, and the standing rule (#2243) is that a destination’s own declaration is a fact and blocks while OUR inference only warns - a silence is purely OL’s inference. Exclusions also accumulate as rows with nothing that clears them, so on the only shipped topology (one executor,openlinker.oms.v1, #2409) a blocking timeout would permanently exclude the only possible holder after a transient outage, with no in-product remedy. The loopblockingexists to terminate cannot occur yet either, since nothing re-sources - a forward constraint on #2395: because a timeout deliberately does not exclude, the re-source loop must bound its own attempts rather than rely on exclusion to terminate. (2) It adds NO writer ofrequestStatus- it reaps THROUGH the unchangedrecordRejection, so the guardedsubmitted -> rejectedtransition and the rejection row stay one statement pair with one owner; the repository’s per-column writer table names five and an UNNAMED sixth is the defect that table exists to prevent. (3) It is frontier-as-query, with no cursor at all. The candidate set isrequestStatus = 'submitted' AND updatedAt < cutoffand every page CONSUMES its own selection, so an advancing scan offset would step over rows - here a work that is never reaped, i.e. the exact defect the pass fixes.bounded-sweep.tsdraws that distinction in its own header and #2317 / #2346 recorded the same reasoning; the sweep PRIMITIVES (budget clamp, lock TTL) are reused and the offset machinery is not, and noMasterSweepKindmember is added becausesweepLockKeyrendersmaster:{kind}:sweep:{id}, a false name for a pass with no master. (4) The reap PINS the attempt it read (RecordFulfillmentRejectionInput.expectedAssignmentAttempt, optional and therefore purely additive, so the handshake is byte-identical by omitting it): the sweep is a delayed actor by definition, and a row that left and re-enteredsubmittedinside the read-then-write window would otherwise take a rejection naming an attempt that is no longer live. Thesubmittedguard alone is what makes a late acceptance and a reap race on ONE row with exactly one winner - asserted by an OVERLAPPING integration test against real Postgres, since a sequential one passes against no guard at all. (5) The deadline is resolved through ONE path (resolveFulfillmentDispatchTimeoutMs,OL_FULFILLMENT_DISPATCH_TIMEOUT_MS, default 2 h, clamped[5 min, 7 d]), feeding both the cutoff and the operator-facing sentence stamped on the rejection - #2229’s rule, since a number an operator reads that drifts from the one that acted is worse than no number. The floor is a safety bound rather than a formality: below it the sweep reaps dispatches a healthy holder is still answering, and a reap FREES the work. The clock is “idle since” (updatedAt), not “submitted since”, which can only ever DELAY a reap and never accelerate one. (6) The A3-X (fulfillment-unaccepted) state it raises is LEVEL-TRIGGERED, not sticky -deriveAcceptanceAttentionreads EVERY work object on the order, so it is split-safe (omsAttentionis keyed(order, producer), and a per-work answer would let one parcel’s acceptance clear a stuck sibling’s flag) and it also covers a holder’s own rejection, which is correct because A3-X’s descriptor reads “every candidate rejected or timed out”. The dispatch handler recomputes it after every handshake outcome, so an accepted re-dispatch clears the mark. The verdict is REPORTED and the worker writes it throughIOrderRecordService.markOmsAttention(#2352’s first production writer): this leaf may not inject anordersservice, and the evidence the split is right is that #2712 adds zero entries tocheck-no-injection-contracts.mjs’s orbarrel-purity.spec.ts’s allow-sets.bulklane (a cron-paced reconciler over work already stalled by definition, unlike its tworealtimefulfillment.work.*siblings), global scope under the nil-UUID system connection, hourly at35 * * * *, default ON - it makes no platform call and is inert on any install that has not opted into routing, sincefulfillment_worksis empty there. No migration:IDX_fulfillment_works_request_status(['requestStatus','updatedAt']) was created by #2392 for this sweep by name. -
Work is never routed to a holder that cannot execute, and work that was never offered is surfaced and re-driven (#3476):
fulfillment.work.dispatchresolves its executor throughgetCapabilityAdapter, which is gated onenabledCapabilities, and it does so BEFORE the handshake claims the work. So an OMS connection withoutFulfillmentExecutorenabled made every dispatch job dead-letter, leaving the workunsubmittedfor ever with no alert. That connection is reachable two ways: one created between #2405 and #2409 (enabledCapabilitiesis never back-filled, #2085), or an operator unticking the capability. Three parts close it. (1) Refused at assignment:createOmsFulfillmentRouterResolverreads the capability and hands the router a REQUIREDholderCanExecute. Onfalse, the pipeline eliminates the holder’s candidates before any operator filter, with an explanation step, and reports every line unfulfillable under a fixed reason (HOLDER_CANNOT_EXECUTE_REASON). The resolver still returns a router rather thannull, becausenullwould mirror the order to every product master. The committer refuses the plan, the order is heldrouting-refused, and #3485’s reroute sweep routes it once the capability is enabled. The router’s per-line reasons now ride on therefusedoutcome (unfulfillableReasons, distinct and sorted so the detail is stable across re-routes) intofulfillmentBlock.detailand the UF-L detail. Before this the operator saw onlyplan-carries-unfulfillableand could not tell “out of stock” from “the holder cannot execute”. (2) Surfaced: the #2712 timeout sweep gains a second pass over live, holder-assignedunsubmittedwork idle past the same dispatch timeout, and raises A3-X throughderiveAcceptanceAttention’s opt-installedUnsubmittedBeforearm. Every caller that rewrites the order’s acceptance entry passes the cutoff, so the state stays split-safe. The pass is KEYSET-paged from a cursor (fulfillment.unsubmitted.after), not frontier-as-query, because raising attention writes nothing to the work row, so the set never shrinks. (3) Re-drivable:buildFulfillmentDispatchDedupeKeytakes an optional generation, and the pass re-enqueues the dispatch underfulfillment:dispatch:{workId}:redrive:{floor(now / timeout)}. It does so only once the holder’s connection is active withFulfillmentExecutorenabled, so a still-misconfigured connection does not fill the queue with dead jobs. The handshake claim, not the key, is what prevents a double dispatch. -
A dispatch relay that never landed is re-driven from OL’s own rows, never by replaying the event (#2728, ADR-054):
fulfillment.work.relaySweepre-drives the lifecycle relay for aFulfillmentWorka holder reported SHIPPED whosedispatchRelayedAtis still NULL. #2401 made that claim RELEASABLE, which frees the SLOT and causes no retry:IFulfillmentProgressService.recordburns the(workId, idempotencyKey)progress claim BEFORE reporting the relay intent and returns no intent for a duplicate, so replaying the same vendor event produces nothing to re-drive - and a second, differently-keyed event arriving WHILE the first relay is failing sees the claim held and answersalready-relayed, so recovery would need a THIRD. If the holder’s next event isdelivered, there is no third and the marketplace is simply never told. Six properties are load-bearing. (1) The progress claim is NOT released, and that is the whole reason this is a sweep. #2400 argues that key is PERMANENT MEMORY rather than a held slot - explicitly against the partial-unique precedents (reservations WHERE status = 'held',order_changeson the open statuses), both partial precisely because they express slot-holding - so un-burning it would let a replayed vendor event re-move counters, trading a missed relay for corrupted quantities.libs/core/src/fulfillment/__tests__/no-progress-claim-release.spec.tsfails the build if a release is ever added to the port or reached around it, and the int-spec asserts the row survives a real run. (2) The re-drive goes THROUGHclaimDispatchRelay, never around it - the sweep resolves candidates and hands each to the shipment-firstIFulfillmentWorkDispatchRouterService.routeDispatch(core/shipping, #3506), whose two paths both take the work’s one conditional slot claim (claimDispatchRelayinside the unchangedrelayDispatch, ormarkRelayedExternallyafter a shipment-grain notify lands), so the conditional UPDATE stays the serialisation point between this pass and any concurrent progress-driven trigger, exactly asShipment.waybillRelayedAtis between the status poll and the carrier webhook (#1947). A sweep that claimed for itself would be a second writer of that column and would defeat the guarantee. Only a TRANSIENT failure releases and stays on the frontier; a structurally unsupported relay keeps the claim, so the work leaves and is never re-driven again. (3) “Shipped” is read off the progress claim’seventKind, which has no alternative source -FulfillmentProgressService.apply’sshippedarm writes nothing to the work row, so the claim is the sole trace. That NARROWS the port’s “for forensics. Never read as state” note rather than contradicting it: the state that note protects is the WORK’s, which the two axes own, while a claim row is evidence of what arrived. AshippedAtcolumn onfulfillment_workswas declined - a sixth writer on a five-writer table, a second source of truth, and backfillable only from the sameeventKindread, leaving every pre-migration shipped work permanently invisible. One new non-unique partial index (IDX_fulfillment_progress_claims_shipped) serves the frontier; non-unique is load-bearing, becauseclaim’s bareON CONFLICT DO NOTHINGis safe only while the composite PK is the table’s one uniqueness declaration. (4) Frontier-as-query with no cursor - a repaired work leaves the set by acquiringdispatchRelayedAt, so an advancing scan offset would step over rows, which here means a work whose source is never told. The sweep PRIMITIVES are reused and the offset machinery is not, and noMasterSweepKindmember is added (sweepLockKeyrendersmaster:{kind}:sweep:{id}, a false name); the budget is DERIVED rather than copied at 25 per run, because one unit is a lifecycle relay fanned out to N participant adapters rather than #2712’s bounded local write. A grace window keeps the pass off relays that are still legitimately in flight. (5)bulklane, on a STRONGER argument than #2712’s - every candidate has by construction been unrelayed for at least the grace window, so a lane-slot’s delay adds minutes to a condition measured in hours; and unlike that reaper, which makes no platform call at all, each candidate here is heavy outbound work, which is the last thing that should hold a slot from a queuedfulfillment.work.dispatch. Global scope under the nil-UUID system connection, hourly at50 * * * *offset from the timeout sweep’s35, default ON - it writes no row of its own, so a spurious run’s worst outcome isalready-relayed. (6) The escalation is an AGE bound, counted and logged rather than persisted (decision 4, the #2346 shape): pastOL_FULFILLMENT_RELAY_STUCK_AFTER_MSa candidate is counted on the job result and error-logged underfulfillment_relay_reconcile_stuck, and is still re-driven, sinceadapter-unresolvedis transient by #1947’s classification and withholding would make a recoverable condition permanent. #3013’s failure-COUNT columns deliberately do not transfer one grain up:FulfillmentWorkViewexcludes relay hygiene by design, so counters would have no reader, whereas the age is already persisted on the claim row and directly SQL-queryable. A page in which nothing could be relayed is surfaced (fulfillment_relay_reconcile_page_all_stalled), because candidates are oldest-first and a permanently-failing one keeps its place at the head. No longer inert since #3525 -IFulfillmentProgressService.recordnow has a production caller (FulfillmentParcelClosureNotifierService, the bench close + the desktop worklist’s manual close), so ashippedclaim IS created whenever an OL-executed parcel closes, and this frontier can find real candidates onceFulfillmentDispatchRelayService.relayDispatchfails transiently for one. Review correction (#3525): a work whose closure routed through the SHIPMENT-grain path never enters this frontier at all. For the ordinaryol_managed_carriercase - a bought label, #2402’sfulfillmentWorkIdlink, stillgenerated- the notifier drivesIShipmentDispatchNotificationService.notifyDispatchedinstead of this work-grain relay (tracking number + carrier, and it advances the shipment, feeding #2347’s reservation consumption), and claimsdispatchRelayedAtdirectly via the gate’s newmarkRelayedExternallyon success - a THIRD way that column gets set, alongside “claimed and relayed” and “released after a failed relay”. Only work with no linked shipment, or more than one (a split order), goes through this work-grain relay and, on transient failure, reaches this frontier. A FAILED shipment-grain notify also reaches this frontier, and since #3506 (G02-4) it is retried THROUGH THE SHIPMENT: the routing decision moved out of the notifier into the sharedFulfillmentWorkDispatchRouterService, which both the notifier and this sweep call. Before that the sweep only knew the work-grain relay, so a close whose source rejected the shipment-grain notify was “recovered” with a tracking-lessdispatched- the channel showed SENT with no waybill, the slot was burnt and the shipment stayedgenerateduntil an operator clickednotify-dispatched. Now a shipment-grain re-drive that lands advances the shipment and claims the slot (via-shipment, countedrelayed), and one that fails again leaves the slot open (shipment-failed, countedreleased) so the work stays on the frontier and, past the stuck bound, escalates - it never degrades to the tracking-less relay. The manual triggerPOST /v1/sync/jobsaccepts the nil UUID (@IsUUID('all')), so a hand-run sweep takes the samefulfillment:work:relay-sweep:{scopeId}lock as the cron. Reachability still depends on the OMS being enabled and the work’s holder being OL’s own executor (the #2400 / #2955 posture for every OTHER caller). The issue’s second gap is NOT closed and is not implementable yet: a waybill arriving after a successful dispatch relay has no source at work grain at all - neitherFulfillmentShippedEventnorFulfillmentProgressSnapshotcarries a tracking number and no executor adapter reports one - and the premise that a router-fulfilled work has noShipmentto key on is wrong for theol_managed_carriertopology since #2402, where #1947’s late-waybill backfill already relays it. A work-grain waybill claim would therefore relay a duplicate or nothing; closing it starts with widening the executor port, which that port defers to Wave 4. -
The work→shipment bridge is
shipments.fulfillmentWorkId, NOTshipment_lines(#2402,W3a-13) — #2392 deferred this column here, and #2402 resolved that it lands onshipments. Both #2392’s AC and DESIGN §5.2 name ashipment_linestable, and there is none —libs/core/src/shippingdeclares exactly one entity,@Entity('shipments'). Three things decided it. (1) §5.2 contradicts itself in one sentence: “Relationship toShipmentis 1:N with the shipment keeping its identity;shipment_linesgains a nullablefulfillmentWorkId” — for a 1:N work→shipment relation the key belongs on the MANY side, so a line table would express M:N at line grain, which is not the stated relation. (2)shipment_linesis a different concern with a different key and motive — option C ofDECISION-oms-fulfilment-grain, keyed(shipmentId, orderId, lineId, quantity)withorderIdpresent so consolidated shipping across two orders stays expressible, existing to makeshipped_quantityderivable. It carries obligations #2402 has not (a backfill written as ledger events, thefulfillment-rollup.ts“any delivered ⇒ delivered” precedence fix, line counters, an FE panel) and shipped as #2727 — see § 27 Shipment lines, which delivered the first three and deferred the panel. (3) It is a reference by value with no FK — theorder_changes/refund_records/returns.internalOrderIdcross-aggregate precedent: a shipment is not part of a work, it outlives it and is queried independently, and the dispatch path already takes a per-order lock, so an FK would add cross-table lock coupling on a hot write path. The index is full, not partial, unlike the siblingIDX_shipments_reservation_consume_pendingon the same table: that set shrinks to nothing in steady state, whereas this one grows to the majority as routing is adopted, and a partial predicate would also refuse to serve theIS NULLscan the fill-in-when-NULL repair below depends on. The link is fill-in-when-NULL, not write-once: it is stamped at creation by both existing creation sites, andclaimFulfillmentWorkLink(WHERE "fulfillmentWorkId" IS NULL, theclaimWaybillRelayidiom) repairs a branch-1 row minted by the status poll BEFORE its order was routed — which a strict write-once rule would have left unlinkable forever, since the dispatch retry path reuses that row rather than creating one. It carries noassignmentAttemptcompanion: the attempt lives on the work row where #2399’s conditional UPDATE claims it, and it legitimately advances after the shipment exists (a re-assignment does not un-ship a parcel), so a copy would go stale with no writer able to correct it. The work is resolved by ORDER alone, never by connection — the branch-1 path’sconnectionIdis the order-source / OMP marketplace connection, while aFulfillmentWorkcarriesassignedConnectionId, the executor holder; those coincide on anomp_fulfilledtopology and diverge on a routedol_managed_carrierone, so a connection-filtered lookup would silently match nothing and leave every shipment unlinked with no error, failing only on the topology the OMS exists to enable. A split order legitimately has N works, soFulfillmentWorkLinkResolutionnamesambiguousas its own outcome and leaves the column NULL rather than guessing — attributing one whole-order shipment across N works is line grain, i.e. #2727. The OMS mints no shipments: there are two creation sites at the time,ShipmentDispatchServiceandFulfillmentStatusSyncService.createBranchOneShipment(the ADR-012 branch-1 observed row DESIGN §5.5 itself names as the 3PL source), and #2402 adds no third. A third has since been added outside the OMS:SourceFulfillmentStatusSyncService, the source-side twin of the branch-1 row (a shipment the order’s source reports was made outside OpenLinker), which likewise never writes aproviderShipmentId;shipment-creation-sites.spec.tspins the list — which is the honest reading of its AC, since the AC as written was already false onmain. A 3PL therefore never gets a fabricatedproviderShipmentId, becausedispatch()resolvesomp_fulfilledand mints nothing. Both halves resolve the work THEMSELVES rather than accepting it as an input, and that is a correctness choice rather than convenience: an optionalShipmentDispatchInput.fulfillmentWorkIdhad no caller anywhere in the tree — no HTTP DTO, no worker handler, no bulk site — so the entireol_managed_carrierhalf of the bridge would have been unreachable, exercised only by its own spec, on exactly the topology the order-only lookup exists to serve.ShipmentDispatchServicetherefore resolves the link through the sameIFulfillmentWorkQueryServicethe branch-1 path uses. Both resolutions are best-effort: the link is provenance and the label is the operator-facing act, so a fault in the fulfilment context degrades to an unlinked row and never fails a dispatch or a status projection. One consequence is written down where it is caused rather than left to be discovered: a branch-1 row that reaches a TERMINAL status before its order is routed is permanently unlinkable by the repair path, because that repair sits inside the changed-patch gate anddiffPatchnever opens it again — free today since nothing reads the column, but #2727’s rollup will need a backfill. Deferred: an automaticshipped→dispatch()trigger is #2729 —ShipmentDispatchInputrequiresrecipientandparcel, and while a pure recipient projection exists inapps/web(features/orders/lib/dispatch-input.ts, unusable from core per #591 — a mirror-or-move decision, not reuse),parcelweight and dimensions are operator-typed and derivable from nothing, so an automatic trigger would have to invent the values that decide what the carrier charges. -
It ships a
fulfillment.tokens.tsfrom day one, unlike the other two OMS leaves, and that is the tokens-exemption rule applied rather than deviated from: the exemption covers a vocabulary-only concern and expires “the day the concern needs a binding” — nameable here as #2392’sFULFILLMENT_WORK_REPOSITORY_TOKEN. -
Nothing consumes it yet — the #2304 “vocabulary ships first” posture, so the contexts that adopt it adopt one spelling. #2392 persists it, #2395 creates it in one transaction with the routing decision, #2398/#2399 carry it across the executor port, #2406 projects it with
supportedActions. -
Progress ingress — the
'fulfillment'inbound domain,fulfillment.work.statusSyncandIFulfillmentProgressService(#2400, DESIGN §5.4/§5.5): progress had a contract and no transport.CanonicalInboundEvent.domainis a closed union, so a vendor decoder reporting a pick would authenticate every delivery and then dead-letter it — the documented eparagony trap (§16, ADR-042). The union grows by two ('fulfillment', gated onFulfillmentExecutor;'return', gated onOrderSource), each with aresolveRoutearm, and thenever-exhaustivedefault:is what forced both to be written rather than defaulted. Five properties are load-bearing. (1)record()REPORTS a relay, it never fires one. Firing means importing@openlinker/core/orders, whichscripts/check-no-injection-contracts.mjsandbarrel-purity.spec.tsindependently forbid under this directory — and that prohibition is ADR-053’s report-don’t-perform discipline (the #2100SalesDocumentBlockOutcomeseam), not an obstacle routed around.FulfillmentProgressOutcomecarriesFulfillmentRelayIntent[]; the caller composes. The evidence the placement is right is that the change adds zero entries to either guard’s allow-set — needing one would have been the signal it was wrong. In particular the DESIGN §5.5 re-route after ashort_pickedis reported as an intent, because re-enteringroute()needs #2395’s router, the routing lock andorder.cancelledAt, all outside this context. (2) The dedup index is UNCONDITIONAL, and the enforcement is the index.fulfillment_progress_claimscarries a composite primary key(workId, idempotencyKey)over the whole table. The partial-index precedents (reservations WHERE status = 'held',order_changes WHERE status IN (…)) express slot-holding, where a terminal row must not block a legitimate fresh holder; a progress dedup key is the opposite — permanent memory, where a replay must be a no-op forever, so any predicate letting a row leave the index reopens the window in which a replay re-moves counters and re-fires a relay. It is claimed with the #2360INSERT … ON CONFLICT DO NOTHING/raw.length > 0idiom, never aSELECT-then-INSERT, which at READ COMMITTED enforces nothing because the conflicting row is a phantom that cannot be locked before it exists. The bareON CONFLICT(TypeORM 0.3.17’sorIgnorediscards its argument) is safe only while that PK is the table’s one uniqueness declaration — a second unique index added without first giving the insert a column-list target would make an unrelated conflict report “already claimed”, permanently and silently suppressing a progress write. (3) The ORDER insiderecord()is the contract: read work → claim → mutate → build intents. Reading first means an unknown id never burns a permanent key; claiming before mutating is REVIEW C9’s requirement; and an intent is only ever returned for state that was written. A guarded update answeringfalsebecomes a namedprecondition-failed, never a throw — theFulfillmentWorkRepositoryPortconvention thatfalseis an ordinary outcome. (4) This handler still callsrecord()for nobody, deliberately — but the seam has its FIRST production caller since #3525.apps/api’sFulfillmentParcelClosureNotifierServicecallsrecord({kind: 'shipped', ...})from BOTH places a parcel can close (the pack bench’s automatic close and the desktop worklist’s manualcloseaction) — the “an operator action” arm this file’s own header names as one of the seam’s three intended callers, alongside the executor webhook and the pollingFulfillmentStatusSourcebelow. This particular handler (fulfillment.work.statusSync) still acknowledges the trigger and completes without callingrecord()itself: the only data an inbound webhook offers isCanonicalInboundEvent.payload, which core documents as “Non-authoritative payload hint; never source of truth”, so building a progress event from it would move real fulfilment counters off an unauthenticated body — exactly what the webhook-as-trigger discipline (#904) prevents, and whyFulfillmentWorkStatusSyncPayloadV1carries a reference and no deltas. #2398’sFulfillmentStatusSourceis the authoritative read for a polling holder and remains uncalled; widening the payload to carry deltas is the one change that must not be made to give the handler something to do. This is the #2393 / vocabulary-leaf “ships ahead of its consumer” posture. (5)fulfillment.work.statusSyncis NOTmarketplace.fulfillment.statusSync(#834, the shipping context’s OMP read-back) — unrelated job, service and meaning; the OMS one is namespacedfulfillment.work.*on theinventory.reservations.*precedent because its trigger is an executor and not necessarily a marketplace. It runs onrealtime: ADR-050 picks by cost of starvation, and a progress report is waited on by a picker at a station.awaiting_waveis absent from the event union and named as its first extension point (the ADR-045packGrainlesson). Resolving a vendor’s own reference to an OLworkIdis #2399’s, which owns the executor handshake and therefore knows whether that reference is per-connection or intrinsic to the row;record()takes aworkId— an internal id entering as an argument, ADR-053’s stated discipline — so noexternalWorkIdcolumn is guessed at here. -
The
'return'arm’s gate is over-permissive in one direction, and that is handled where it can be handled honestly (#2400): gating onOrderSourceis correct —ReturnSourceReaderis guard-only, absent fromCoreCapabilityValues, soenabledCapabilitiescan never contain it and gating on it would leave the arm permanentlyungated(the #2085 stamped-at-create trap). The cost is that a plainOrderSourceconnection with no return reader (PrestaShop, WooCommerce) passes the gate and enqueuesmarketplace.return.sync, which then fails at the narrow. That failure used to be a bareErrorthe handler rewrapped as retryable, spending the full ten-attempt ladder and a dead row on a structural condition no retry can change.ReturnIngestionServicenow raises the namedReturnSourceNotReadableErrorand the handler answers a terminalbusiness_failure(ADR-007) — the treatmentReturnObservationMissingExternalIdErroralready had beside it. -
How a router is ASKED, and what it is allowed to see (#2393, DESIGN §5.3, ADR-062 Decision 2):
FulfillmentRouterPortcarries two methods —evaluate()(non-committing) androute(input, options)(committing) — plus the routing I/O that crosses them. Five properties are load-bearing. (1) The return type can say no:RoutingPlan.unfulfillableresolves lines as line-scoped refund or return, a closed two-member union, never an invented partial-cancel state no source can express. (2)evaluate()is non-committing as a CONTRACT-level property, not a runtime guarantee — a third-party router can do what it likes inside itself and core cannot forbid it; what the contract guarantees is that no committing identifier travels in either direction, sinceRoutingEvaluationcarries nodecisionId(so nothing can be persisted off an evaluate result) andevaluatetakes no options (so it cannot be handed an idempotency key). ADR-044 records that nothing else planned produces a dry run. (3)RoutingInputis an explicit ALLOWLIST projection, never a spread of an order — the router is a connection-backed plugin (ADR-055), so the shape is handed to code OL does not own, and the MCP-tools discipline applies: buyer name, email, phone, street address, tax id and theOrderentity itself never cross, and the allowlist is exported as data so widening the projection is a deliberate two-place edit. Core projects and selects; it never derives. The SHAPE is bounded by construction — no arm can carry a field its allowlist does not name, and atscerror says so — while the FIDELITY of the hashed arm is explicitly the caller’s: both the PII flag and the hash are supplied and neither is validated, because core cannot tell a genuine address hash from any other string (#2395 owns that, and must first add the address-projection readICustomerProjectionServicedoes not have). UnderOL_STORE_PII=falsethe degraded arm carriescountryIso2plus a caller-suppliedlocationHash— the ingestion-timecustomer_address_projections.addressHash— and NOT a hash recomputed here, because the persisted snapshot’s address has already been throughredactAddress, so hashing it would yield one value per COUNTRY, silently grouping the whole install behind a plausible 64-hex string. A missing hash reportsnullrather than a fabricated grouping key, and the country survives in both arms because a country code is not PII andcountry-servedis the primary filter. One hash rather than one per field is deliberate: a hashed postcode admits neither prefix nor range matching, so per-field hashes would look like they preserved zone routing while preserving nothing. (4) Rule names are OPAQUE strings with display labels (REVIEW H7) — the closed named filters and sorts and their coercer belong to@openlinker/oms, so a vendor’s own rule names render in the explanation an operator reads. (5)RoutingPlandeclares a second,pending {decisionId}arm (REVIEW G5’s third) for a genuinely asynchronous DOMS (REVIEW G5) and Wave 3a REFUSES it with the namedPendingRoutingPlanNotSupportedError— treating it as resolved would proceed with no assignments and read as a router that successfully decided to create no work; consuming it isW4-3.FulfillmentRouteris deliberately absent fromCoreCapabilityValuesand from every manifest (#2403 — A2 isconfig-only): nothing can dispatch the name, and advertising it would invite a gate onenabledCapabilities, which is stamped at create and never retro-filled. Note the port is not theFulfillmentRouting*family inlibs/core/src/mappings— that one answers “which processor or carrier DISPATCHES this?” and this one “which location and holder SOURCES this?”. Per-method error unions, wall-clock budgets, a declaredmaxBatchSizeand an order-freshness token are Wave-4 hardening (W4-1/W4-2). -
How a holder is ASKED to do the work, and how it can say no (#2398, DESIGN §5.4):
FulfillmentExecutorPortcarriesrequestFulfillmentandrequestCancellation, plus the optionalFulfillmentStatusSourcesub-capability that serves a POLLING holder. DESIGN §5.4 names three implementer shapes for the one port - a 3PL adapter (API submit + webhook progress), the OL-OMS plugin (auto-accept + pick list), an enterprise DOMS (the 3PL shape with a richer reject vocabulary) - and nothing in core knows which, which is why the reject reason is an opaque string and the progress read is optional rather than required. Six properties are load-bearing. (1)blockingon a rejection is a loop terminator, not a severity flag - without it, re-source plus a deterministic sort re-picks the holder that just refused, for ever, by construction. It is non-optional, and that is the load-bearing half:blocking?: booleanreadsundefined, which is falsy, so the rejecter would not be excluded and the loop would run anyway - pinned by a compile-time guard rather than by a field round-trip, which would assert only that object literals work. (2) Both methods answer with the same result type, because cancelling work a holder has ALREADY ACCEPTED is a request that holder may refuse (FulfillmentRequestStatus.cancellation_rejected, ADR-054’s whole reason for two axes); avoidcancellation would assert a compliance the contract cannot obtain. The two result arms deliberately spellaccepted/rejectedexactly as that negotiation axis does, since #2399 stampsFulfillmentWork.requestStatusstraight from them - a correspondence a spec pins, not a collision to tidy away. (3) The idempotency key is mandatory AND carries a stated guarantee -work:{workId}:{assignmentAttempt}, and a repeat under the same key must return the ORIGINAL outcome and never create a second assignment. Stating only the format would leave an implementer a field it cannot honour and would make the counter’s design (bumped by a router-driven re-request, written before the outbound call, never by the job-runner attempt that changes on exactly the retries the key must survive) meaningless. (4)FulfillmentRequestis an explicit ADR-062 allowlist that reusesRoutingShipTorather than declaring a second ship-to shape - so this port opens no new PII surface and spends no newZERO_SIBLING_EDGE_LEAVESentry; its arms, allowlist and forbidden-key guards are the ones #2393 already ships, and a second projection would have been a second PII allowlist to keep aligned. Lines carry the work line, the variant and the quantity only: an adapter holdsidentifierMappingin itsHostServicesbag and resolves what its vendor needs, while a domain entity handed to a plugin re-opens every field it will ever grow. (5) An unrecognised status FAILS CLOSED -assertFulfillmentRequestResultRecognisedtests POSITIVELY for the two known values rather than against one known-bad value, because a third status (a future arm, a typo, another vendor’s vocabulary) would otherwise fall through to the rejected arm’s reads and yieldblocking: undefined: the loop again, arrived at by a silent misread. A rejection itself never throws - it is a consumable, expected outcome the caller re-sources on, unlikeassertRoutingPlanResolved’s unconsumablepending. (6) A holder-reported instant is the HOLDER’s (#2336/#2367/#2371) -acceptedAtandobservedAtarenullwhen unreported, nevernew Date(), since OL’s clock is not a witness to a third party’s act; and the progress snapshot is counter-shaped, never per-line statuses (ADR-054) and carries no negotiation status at all, which would be a rival authority over the column #2399 owns.FulfillmentStatusSourceis advertised-without-dispatch, narrowed by a runtime method probe (ADR-055 R1 / the ADR-046 resolver precedent, so an adapter compiled against an olderlibs/coredegrades rather than throwing) and absent fromCoreCapabilityValues- a spec pins that absence beside its base port’s presence. Its method isgetWorkFulfillmentStatus, deliberately diverging from DESIGN §5.4’s originalgetFulfillmentStatus(the design carries the amendment):ordersshipsFulfillmentStatusReader.getFulfillmentStatus({externalOrderId})whose guard is a baretypeof adapter.getFulfillmentStatus === 'function', so under the original name an adapter implementing THIS capability would satisfy THAT guard - one-directional, needing no class that implements both ports - and be called byFulfillmentStatusSyncServicewith{externalOrderId}while expecting aFulfillmentWorkRef. That is precisely the ADR-046isOfferFieldUpdaterand #2229isEanCategoryMatcherStreamingshape, both fixed by probing more narrowly and neither fixable by a comment - a distinct name makes the mis-narrowing impossible where a docblock only asks the next author to notice it. A spec pins it by asserting the executor is NOT narrowed by the foreign guard while an object under the old name IS. Note also thatFulfillmentRequestResultcarries a per-arm allowlist of its own: the inbound/outbound distinction bounds PII exposure but not PERSISTENCE, and #2399 stampsrequestStatusfrom this result, so an unreviewed field a plugin returns is a field core may write (the #2327rawPayloadclass). Per ARM becausekeyof (A | B)is the intersection, so one list checked against the union would see onlystatusand pass whatever either arm grew.FulfillmentExecutorIS inCoreCapabilityValues(#2403), unlikeFulfillmentRouter, because A3 resolves by narrowing a dispatched adapter and its name must be assignable - andopenlinker.oms.v1advertises it since #2409, which closed the reachability gap that had made A3 assignable only through a directPATCH /connections/:id(both capability-checkbox surfaces intersect an adapter’s advertised list with the core set). Progress INGESTION is deliberately not on this port - it is inbound, andIFulfillmentProgressService.record(#2400) is its seam; the accept handshake and theassignmentAttemptcounter are #2399, and a partial cancellation is expressible as cancelling a narrower work object (ADR-054) rather than as line-scoped fields. -
The first implementation of that port is the OL-OMS executor (#2409,
libs/oms/src/execution/ol-fulfillment-executor.adapter.ts, DESIGN §9), and it is the shape ADR-055 predicts: the plugin descriptor IS the OMS’s adapter to OpenLinker, with the only asymmetry below the port line, where the answer comes from OpenLinker’s own tables instead of a vendor API. It auto-accepts, and four properties are load-bearing. (1) It holds no state, and that is the STRONGER guarantee rather than a shortcut. The port requires that a repeat under one idempotency key replays the original outcome and never creates a second assignment; a vendor adapter honours the second half by remembering keys, while this one honours it vacuously - the assignment is the work row’s holder assignment, which CORE owns, so there is no vendor-side order for a second call to duplicate. The first half then follows by construction rather than by memory, which is stronger because a store can be lost. The consequences are concrete: nooms_*table, no TypeORM dependency inlibs/oms, no migration, and no injected deps - soOmsModulestays oncreateNestAdapterModuleand does not pay the ADR-051 cost #2405 predicted (a hand-written@Modulewould dragShippingModule/MappingsModuleproviders into theevents,schedulerandmaintenanceworker roles). (2) Auto-accept goes THROUGH the real handshake claim, never around it - nothing in the adapter writes a row;FulfillmentHandshakeServiceclaims the attempt viaclaimDispatchAttempt(whose attempt reaches the caller only throughRETURNING, so minting a key ahead of its row is inexpressible) and records the answer through the guardedrecordAcceptance. “Auto-accept” names the ANSWER, not a shortcut past the negotiation. (3)acceptedAtisnull, and it is FORCED rather than chosen. OL is the holder here so its clock would be a legitimate witness, but #2404’s contract callsrequestFulfillmenttwice under one key and requires a byte-identical outcome - so a freshDateper call fails the contract, and the only replay-stable instant an executor without its own store can offer isnull(a documented first-class value, told apart from “never accepted” byrequestStatus). One consequence is reported rather than buried:recordAcceptanceis guarded on"requestStatus" = 'submitted' AND "acceptedAt" IS NULL, and writingnullmakes that second conjunct non-narrowing for OL-executed work, so at-most-once acceptance rests on therequestStatusconjunct alone - not a live defect, since nothing else writes that status, but a defence-in-depth layer thinned. (4) It deliberately does NOT implementFulfillmentStatusSource, and the absence is asserted positively rather than inferred from the contract kit’s silence. That sub-capability is the pull serving a POLLING holder; this holder is in-process, sogetWorkFulfillmentStatuswould readfulfillment_works’ own counters and report them back as observed progress, whichIFulfillmentProgressService.recordthen writes to those same counters - a closed loop, and the second-source-of-truth the design exists to avoid. So #2409 does not becomerecord()’s first production caller; progress for OL-executed work arrives from the operator surfaces that close a parcel — the bench (#2416/#2418) and the desktop worklist (#2406) — viaFulfillmentParcelClosureNotifierService(#3525), which ISrecord()’s actual first production caller. Two things this makes reachable are worth knowing: A3 becomes assignable in the UI for anopenlinkerconnection, andfulfillment.work.dispatchcan now resolve an executor instead of raising its retryable “could not resolve” error - neither activates on its own, since an OMSConnectionrow exists only once an operator enables the OMS (ADR-055’s never-seeded rule) and the capability must then be enabled on it. A gap this change made reachable rather than introduced, and now CLOSED by #2738: neither the adapter nor the handshake used to check the EXECUTION axis before cancelling (requestCancellationguarded onrequestStatus === 'accepted'alone, which for OL work staysacceptedfor the work’s life), so a cancellation against already-shipped work answeredaccepted.FulfillmentHandshakeService.requestCancellationnow checksisTerminalFulfillmentWorkStatus(work.status)BEFORE crossing the port at all and answers the handshake’s ownnot-cancellableoutcome (a member ofFulfillmentHandshakeOutcomeValuesdistinct fromno-op, since “nobody to ask” and “too late to ask” are different facts an operator surface needs to tell apart) - the core-side guard, chosen over an operator-surface-only fix (#2406/#2410) precisely because it covers every present and future executor, including a vendor whose own API would accept the cancellation just as readily as the OL-OMS executor does. “Past the cancellable point” is implemented as exactly the three TERMINAL statuses (closed/cancelled/incomplete) -in_progress(a pick genuinely in flight) is deliberately NOT terminal and stays cancellable, matchingderiveSupportedActions’s own!terminalrule for offeringrequest_cancellationin the first place. -
The shared port-contract kit (#2404,
W3a-15, design §9): one parameterized suite every implementation of a fulfilment port must pass, so the seam gets two-implementer honesty before a second implementer exists — the FIRST implementer is bound to the contract rather than the contract quietly becoming whatever that implementer happened to do. It lives atlibs/core/src/fulfillment/testing/, reached through a new@openlinker/core/fulfillment/testingpackage-exports subpath, and covers two real ports today:FulfillmentRouterPort(#2393, 9 cases) andFulfillmentExecutorPort(#2398, 7 base cases + 4 for the optionalFulfillmentStatusSource). No adapter implements either yet — #2408 / #2409 have not been built — so the kit is stated honestly as covered contract, awaiting implementers, and a consumer is a one-line call rather than a refactor.AvailabilityAuthoritygets no suite: it is declared in ADR-061 with no dispatched adapter anywhere in the tree, and a suite over nothing is the vacuity this issue exists to prevent. Six properties are load-bearing. (1) The rules live in a PURE checker with a thin jest wrapper (checkFulfillment*Contract/runFulfillment*Contract), which is the whole point rather than a style choice: the two suites this repo already ships (runKsefHttpClientContract,runSubiektBridgeContractTests) are jest-coupled throughout, so neither can answer “did this suite actually assert anything?” from outside jest — where anitthat asserts nothing is indistinguishable from one that passes. With the rules in a function returning per-case{id, checks, failures[]}, both “a non-conforming fake fails” and “every case is really covered” become ordinary unit tests, with no nested runner and no global spying. (2) Four vacuity modes are HARD FAILURES, never skips or silent passes, each verified red-first by a deliberate mutation: no usable subject and an empty case table THROW (ContractSubjectMissingError/EmptyContractSuiteError) rather than reporting, because rendering “0 failures” over them is the #2673 shape where “not covered” and “covered and passing” collapse into one green reading; a case that never ran is caught by reported-ids-equal-declared-ids plus a per-casechecks > 0; and a case that ran and asserted nothing is caught by the PRIMARY guard, a declared-equals-covered equality against one deliberate breakage fixture per case, failing on either side so an orphan fixture is a failure too. (3) Thecheckscounter is the SECONDARY guard and says so — it is self-reported, socheck(true, '…')would pass it; what actually holds the suite honest is that every declared case has a fixture proving it can fail, and each fixture must fail its own case plus only the collateral it declares (a “at least the target failed” assertion would pass a fixture that broke everything, proving nothing about the rule). (4) An OPTIONAL sub-capability is handled by ABSENCE, asserted in both directions — neverit.skip. A skipped case reads green and asserts nothing, so an executor that BROKE its status read would be indistinguishable from one that never had one. The status cases are appended to the declared table only whenisFulfillmentStatusSourcenarrows the subject,expectedFulfillmentExecutorContractCaseIdsis the ONE function both the checker and the wrapper call (reported === enforced structurally, the #2229 rule), and the specs assert both readings: a plain executor reports exactly the base ids and no status id, a status-source executor reports base ∪ status. (5) Every rule cites the declaration inlibs/corethat supports it, and a rule with no source is NOT shipped — a mirror stricter than the gate refuses work the destination would have accepted (#2240). Ten candidate rules were dropped on that basis with reasons recorded per case, including two the issue text names:blockingexclusion is dropped for the ROUTER (that port has no such concept) and asserted for the EXECUTOR (fulfillment-execution.types.tsproperty (a)), and declared-timeout respect is dropped from both because the ports defer wall-clock budgets toW4-1/W4-2, so the rule would be a flaky test measuring nothing. Where a stated guarantee is only half-observable the suite asserts the observable half and says so — “never creates a second assignment” cannot be seen from outside the holder, so only “a repeat under the same key returns the ORIGINAL outcome” is checked. (6) Jest globals sit on a subpath the main barrel does not re-export, and that separation is asserted byscripts/check-contract-suite-not-in-production.mjsunderpnpm check:invariantsrather than left to reviewer vigilance — a runtimerequire()reaching this code dies withdescribe is not definedat the far end of a stack trace that explains nothing. Placement inside the context (rather than inlibs/test-kit) follows the two shipped precedents, imports only same-context relative paths so it spends no newZERO_SIBLING_EDGE_LEAVESentry and adds no package dependency, and keeps the kit insidecheck-cross-context-imports.mjs’s walk, which does not coverlibs/test-kitat all. -
The router is REACHABLE, and reachability moved the seam out of
orders(#2408):resolveFulfillmentRouterreturnednullunconditionally, so noRoutingDecisionwas ever committed, noFulfillmentWorkwas ever created, and Wave 3a’s exit criterion was unreachable onmain-RoutingCommitServiceis the ONLY caller ofFulfillmentWorkRepositoryPort.createin the tree, so the whole layer was inert rather than merely unused. The answer now arrives throughFulfillmentRouterResolverPort(resolve(connectionId)), bound host-side to ONE implementation,createOmsFulfillmentRouterResolver(@openlinker/oms), which returnscreateOlFulfillmentRouterfor a connection whoseplatformTypeisopenlinkerandnullfor anything else. Five properties are load-bearing. (1) The seam had to move: building the router needs a location read, an inventory read, a work read and the operator’s ruleset, and a dependency-free module function can obtain none of them - whilelibs/core/src/ordersmay not import@openlinker/oms, where the implementation must live. What is preserved is the property that header defended - exactly ONE body answers “is there a router for this connection?”, shared by #2396’s ingestion intercept and thefulfillment.work.routehandler, because two answers let one site route while the other mirrors, which is a double shipment. (2) The injection is REQUIRED, never@Optional(). An optional token defaulting tonullmakes a host that FORGOT the binding indistinguishable from one deliberately running router-less - and since the router-less path is a silent, fully-specified pass-through, that misconfiguration would never surface: lint, type-check and every unit test pass while the feature does nothing. A host that omits the binding therefore fails at boot, theassertFullLaneCoverageposture. (3)@Global()publishes a module’s EXPORTS, not its providers, so each binding module exports the token - without that line it is invisible toOrdersModule, whereOrderIngestionServiceis declared and where the injection resolves from. The binding module imports the HOST’sIntegrationsModulewrapper, never the barePluginRegistryModule(a@Module({})shell with a staticforRoot, so importing the class yields an empty module andROUTING_RULE_SOURCE_TOKENnever resolves, while callingforRootagain would double-register every plugin). (4) The discriminator isplatformType, notadapterKeyand not the A2 claim. A2 isconfig-only, so ANY connection may claimsourcingAuthority(#2407 gates that claim on having an active location, not on the platform) - building the OL router for a third party’s connection would scope the ruleset to their id, find no rules, and report every line unfulfillable, which the committer refuses asplan-carries-unfulfillable: a configuration mistake wearing the costume of a routing bug.adapterKeyis rejected because anopenlinker.oms.v2adapter would leave every existing connection pinned tov1and silently stop resolving. (5) The threenullcauses are told apart in the log. Both call sites emitted onelog-level line reading “no router is wired”, covering a deliberately router-less install, a non-OMS connection claiming A2, and a connection that could not be read; only the first is routine, so the other twowarnfrom the resolver, where the cause is known. The degenerate default is unchanged and still pinned by four characterisation tests inorder-ingestion.service.spec.ts- a router-less install is byte-identical, including the whole-objecttoEqualon thesyncOrderrequest. ONE exit-criterion gap remains open and is filed rather than papered over: no dry-run surface (#2954 -FulfillmentRouterPort.evaluate()has no caller anywhere). The other two closed after this bullet was written - authoring the ruleset with #2953, and thefulfillment.work.dispatchproducer with #2955 - see the two bullets below. -
The router counts live stock only (#3481, epic #3460):
OlFulfillmentRouter.loadStockread positions throughIInventoryQueryService.listInventoryItems->InventoryRepository.findMany, the one inventory read with noisStale = falsefilter, so routing counted rows the #2322 / #3206 repairs had staled (the located row left behind afterstockLocationOverrideis cleared) and rows of products deleted at the master (#1689) - units that do not exist, committed as work nobody can pick.InventoryFilters.excludeStaleis an OPT-IN filter the router now always sets; absent keeps every row, which is exactly the pre-#3481 behaviour every otherfindManycaller relies on, because the operator-facing inventory list shows stale rows on purpose. Repository-level only, likesourceConnectionId: no request DTO exposes it. Subtracting OL’s own published holds in the router is a separate question (#3480). -
The router also leaves out stock OpenLinker has already promised (#3475, epic #3460): after #3481
OlFulfillmentRouter.loadStockstill read rawavailableQuantity, so two orders for the last unit could each be given aFulfillmentWorkfor it. It now subtracts, per(location, variant), the live non-staleheldreservations stampedatpEffect = 'published'- the same definitionIAvailabilityServicesubtracts (ADR-061 § Amendment (#3475)), read through the newIInventoryQueryService.listPublishedReservedByLocationrather than by makingAvailabilityScopelocationanswerable. Four properties are load-bearing. (1) Published holds only - work lines are deliberately NOT subtracted: a routed order’s hold ispublished(#3480) and is consumed once the sale decrement settles (#3453), which lowers the master’s quantity and rewrites the mirror, so a work line (alive until the parcel ships) would count the same units twice; ablocked/in_doubt/retryabledecrement keeps its hold, so those units stay subtracted. (2) The order being routed is excluded from its own sum (excludeOrderRecordId = input.orderId), because its hold is written BEFORE routing runs and would otherwise make its own last unit look promised to itself. (3) Fail closed: a ledger read that throws failsroute()andevaluate()- never read as zero - and the commit path maps it to the re-routablerouting-failedhold (#3485). A location is floored at zero, since a hold can exceed a mirror the master has since lowered. (4) A concurrency limit remains and is stated rather than hidden: the routing lock is per ORDER, so two orders routed at the same instant can both read the same promised total before either one’s work row exists. The window is narrow - each order’s hold is written at ingestion, before its routing - but it is not closed; serialising routing per variant or location is the follow-up if a pilot shows it. Known gaps: the consume-before-mirror order ofInventorySaleDecrementServiceleaves a window in which a sold unit is in neither the hold nor the mirror (and, when the master reports no resulting quantity, the mirror stays high until the next stock sync), so “each unit is subtracted exactly once” is the intent and not a guarantee; a variant resolving to more than one live position records no hold at all (AmbiguousReservationPositionError), so nothing is subtracted for it - and a multi-location install is exactly where the router chooses between locations, so the subtraction does nothing there until the write path can place a hold on one of several positions; and a pooled position (locationId IS NULL) names no location, so the router neither counts its stock nor its holds. -
The ruleset is authorable over HTTP (#2953):
RoutingRuleAdminPortis the operator-authoring half ofoms_routing_rules, beside the router’s read-onlyRoutingRuleSourcePort- two ports over one table, bound to two Symbol tokens by the same repository, because they answer different questions with different narrowing rules.apps/api/src/oms/exposes it atconnections/:connectionId/sourcing-rules, admin-gated, withGET/POST/GET :id/PATCH :id/DELETE :idplus an exhaustivePUT /order. Before it,ROUTING_RULE_SOURCE_TOKENhad one non-test consumer and an operator could author a ruleset only by inserting rows by hand, so Wave 3a’s “authors an ordered filter/sort list” was unreachable. Six properties are load-bearing. (1) The prefix issourcing-rules, neverrouting-rules-apps/api/src/mappings/http/fulfillment-routing.controller.tsalready mounts the latter for the ADR-012 dispatch surface (#836), andfulfillment-router.port.tsforbids wiring one into the other. Sharing the namespace would declareGET /connections/:id/routing-rulestwice; NestJS registers both and the first wins SILENTLY, so one of the two surfaces becomes unreachable with no boot error and nothing failing, sinceroute-authorization-coverage.spec.tschecks decorators rather than path uniqueness. The response DTOs areSourcingRule*for the same class of reason one level down:@nestjs/swaggerkeys schema definitions by CLASS NAME, so a secondRoutingRuleResponseDtowould silently overwrite the first in the published OpenAPI document. (2) ORDER IS STATED, never derived from insertion time -positionis REQUIRED on create, andPUT /orderrenumbers to a dense1..N. That reorder is exhaustive: a body naming a subset is refused with the missing and unknown ids reported separately, and writes nothing, because a partial reorder leaves the un-named rules at stale positions while looking like it worked. The set comparison happens INSIDE the transaction against aFOR UPDATE-locked re-read, so it is race-free against a concurrent delete; a repeated id is caught by length equality, since a repeat shrinks the requested set without producing an unknown id. (3) The write path validates by round-tripping throughcoerceRoutingRule- the same function the read path narrows with. A per-field@IsIncannot express the kind-to-name pairing, so{kind: 'filter', name: 'nearest'}satisfies “a member of the filter names OR the sort names” and would persist a rule that saves happily and never routes; narrowing the assembled candidate makes “authorable” and “routable” impossible to diverge, and a new vocabulary member is accepted the moment the coercer accepts it. A PATCH is validated as the MERGED row, which is what refuses anamemoved to the wrong kind’s vocabulary. (4) “Live” for the list and the reorder is NOT-RETIRED (effectiveTo IS NULL OR effectiveTo > now), never the unique index’seffectiveTo IS NULL- a rule whose retirement is scheduled in the future is evaluated bylistActiveRulesright now, so scoping on the index’s predicate would leave an actively-routing rule unlistable and unreorderable, silently. The two predicates answer different questions: one prevents duplicates, the other scopes work. (5) An unrecognised row is LISTED, flaggedrecognised: false, and cannot be patched. The read path drops a row this build cannot understand, which is right for routing and wrong for an operator surface - a row that is silently not routing is exactly the one an operator needs to find in order to delete it. Its patch is refused (the merged candidate still fails coercion) with copy naming DELETE as the remedy, since a successful edit would imply it routes. (6) A rule owned by another connection answers 404, never 403 - every by-id predicate carries bothconnectionIdandruleId, because a 403 confirms that a guessed id names a real rule somewhere. Two removal paths are offered distinctly:DELETEis a hard delete including history, whilePATCH { effectiveTo }retires the rule and keeps it - which is what the partial unique index is FOR, so a retired rule coexists with its replacement. Authoring is additionally refused on a connection whoseplatformTypeis notopenlinker(the discriminatorcreateOmsFulfillmentRouterResolveruses), since rules there would persist and be read by nothing - the #2407 shape of refusing a configuration that would decide nothing rather than accepting it silently. -
An order from the operator’s own shop is never routed (#3487, epic #3460): a PrestaShop or WooCommerce connection is usually both the product master and an
OrderSource, so without a filter the intercept routed a storefront order to the pack bench while it also stayed in the shop’s back office - one parcel packed twice.resolveRoutingTargetnow returns the pass-through BEFORE router selection when the order’s SOURCE connection hasProductMasterin itsenabledCapabilities(isOrderFromOwnProductMaster,orders/domain/types/fulfillment-routing-eligibility.types.ts). Three properties are load-bearing. It is capability-driven, never aplatformTypetest. It reads the connection the order ARRIVED through, never its lines, so a marketplace order selling the shop’s products is still routed. And it runs before selection, so an A2 ambiguity is not warned about for an order that would never be routed; it reuses the selection’s existing unfilteredconnections.list()read, so it costs no extra statement. The source connection’s status is deliberately not consulted - an order that did arrive through the shop is a shop order even while that connection is disabled. -
An order another system has been told to ship is never routed (#3488, epic #3460): before router selection, and after the cheaper #3487 own-shop check,
resolveRoutingTargetreturns the pass-through when the order’s ADR-012 fulfilment routing (IFulfillmentRoutingService.resolve) resolves toomp_fulfilled(isOrderShippedElsewhere, same file as #3487’s rule). Three properties are load-bearing. Only a matched RULE counts:resolveanswersomp_fulfilledwithsource: 'default'for every order no rule covers, so testing the kind alone would stop routing every order on a rule-less install while the OMS reports itself on. A failed routing read keeps routing - the operator then sees the order on the bench rather than losing it. The resolve happens at most once per ingestion: a lazy, memoized lookup is shared with the reservation’satpEffect(#2344), so the two readers cannot disagree about one order and no extra statement is spent when both run.processorAvailableis not consulted, since it says whether OL can drive the processor, not who ships the parcel. A marketplace shipping its own order is covered by the source’s assertion, not by a rule (#3636):IncomingOrder.fulfilledBySourceis a three-state fact likebuyerTaxId(#2599) -trueis a positive statement, absent means “not asserted” and is never read as “the seller ships”.AllegroOrderSourceAdaptersets it only when the checkout form reportsfulfillment.provider.id = 'ALLEGRO'(One Fulfillment, per the Allegro swagger’sCheckoutFormFulfillmentProvider);SELLERor an absent provider leaves it unset. It is persisted on the order snapshot, andresolveRoutingSkipReasonskips such an order assource-fulfilledbefore it reads any ADR-012 rule, because the source’s statement is a fact and a rule is only configuration. Other sources leave it absent and are unchanged. Conversely, an install that already carries explicitomp_fulfilledrules for its marketplace delivery methods will not route those orders after enabling the OMS until the rules are changed. -
An order the product master already had is never routed on re-ingest, and every deliberate skip is persisted (#3455, epic #3460): an order ingested while routing was off was mirrored to the product master, and the routine re-ingest (a marketplace status change, the PrestaShop reconciliation poll, a webhook) would otherwise put it on the pack bench as well - packed twice, and its stock lowered twice through #3453’s decrement. The routing resolution now also passes an order through when the PRE-persist
existing.syncStatuscarries asyncedrow (isOrderMirroredBeforeRouting);persistOrdernever writessyncStatus(#2140), so that read is current and costs no statement, and onlysyncedcounts, sincependingandfailedmean the destination does not have the order. Five properties are load-bearing. (1) One field answers “why is this order not on the pack bench?” for all three rules:order_records.fulfillmentRoutingSkipReason(own-shop-order#3487,mirrored-before-routing#3455,shipped-by-other-system#3488), exposed on the order DTO for #3482 to render. It is deliberately NOT afulfillmentBlockReasonvalue - every value there means held, not mirrored, while a skipped order follows today’s path - and it answers a different question from #3014’sdestinationRoutingBlockReason. (2) It is recorded only while a connection claims A2: router selection now runs first, and ano-claimantanswer clears the field before any rule is evaluated, or every storefront order on an OMS-off install would be labelled “pack it in your shop”. (3) First match wins, cheapest first - own shop, then already mirrored, then shipped by another system, the only arm that needs the (memoized) routing resolve - which also fixes which sentence an operator reads when several apply. (4) Level-triggered with the #2100 discipline: written on every ingestion includingnull, outside the ingestion write set, behind anIS DISTINCT FROMguard so the commonnull -> nullpath costs noupdatedAtbump, and left untouched when the routing resolution could not decide. (5) All three rules are decided inresolveRoutingTarget, never later in the intercept - that one answer, resolved before the advisory hold is written, also decides the hold’s insert-onlyatpEffect(#3480), so a skipped order is never heldpublished; checked any later, a storefront order (whose stock the shop has already lowered), an order another system ships, or an order the master already has would subtract the same units twice. The guard sits in the ingestion path only:RoutingCommitServicelives in thefulfillmentleaf, which may not readorders, andfulfillment.work.route’s only producer is #3485’s reroute sweep, which re-enters routing for orders already held by the intercept, so the guard’s decision is never re-litigated there. -
With the OMS as the router, an order routing cannot place is HELD, never mirrored (#3485, epic #3460): #2396 let a refused plan (a line out of stock), a missing shipping address and an intercept error fall back to the destination mirror, so with OpenLinker’s OMS on such an order was created in EVERY active
OrderProcessorManager- with two product masters, two copies, one failing with “Product not found” after it had created a guest customer. OnceresolveRoutingTargetanswersroute, every outcome now keeps the order in OpenLinker. Five properties are load-bearing. (1) One rule, two routing sites:deriveRoutingHoldOutcome(fulfillmentleaf, pure) maps aRoutingCommitOutcomeonto{ held, block, lineAttention }and is used by both the ingestion intercept and thefulfillment.work.routehandler, so the two cannot disagree about one order. A refusal is held asrouting-refused, an address-less order asrouting-no-shipping-address, and an error after the router was selected asrouting-failed(three newFulfillmentBlockReasonvalues; the column istext, so no migration). A failure BEFORE the router is selected still fails open, because the claim state is then unknown and holding on an unknown would strand orders on an install with no router. (2) The first producer of UF-L: a refusal whose plan carried unfulfillable lines writes the order’sroutingattention entry (line-unfulfillable, counted inNeeds attention), a routed order clears it, and every arm outside an OMS routing attempt reportsindeterminate- so an install that routes nothing pays no extra statement. Refusal details are STABLE (the abandon reason, never the decision id): every re-route mints a new decision, and a changing detail would defeat theIS DISTINCT FROMguards and bumporder_records.updatedAt- a live filter axis - for every held order on every tick. (3) Re-routable by construction, and re-routed: a refusal terminalises the decision toabandoned, which frees the live-decision index, so a laterroute()mints a fresh decision.fulfillment.work.rerouteSweep(bulk, global scope, default ON, every 15 minutes) isfulfillment.work.route’s first producer: it enqueues one route job per order held asrouting-refused/routing-failedunder the SELECTED router’s connection (never a synthetic id, #2609), and the handler persists the new hold state through the same rule. It pages by KEYSET oninternalOrderIdfrom aconnection_cursorscursor, deliberately not frontier-as-query like its timeout- and relay-sweep siblings: a re-refused order rewrites an identical block and so never leaves an oldest-first frontier, which would re-read the same head page for ever.routing-no-shipping-addressis not swept - only a re-ingestion can bring an address. With no router selected the sweep enqueues nothing and never releases held orders to the masters. (4) The advisory hold follows the order:routedByOmsis nowroutingTarget.kind === 'route'alone (#3480 also required an address), because an address-less order no longer goes to any master and its units are OpenLinker’s to promise; the insert-onlypublishedstamp survives the later route and the #3453 sale decrement consumes it. (5) Visible:OrderRecord.fulfillmentBlockreads the previously write-onlyfulfillmentBlockReason/fulfillmentBlockDetail(coerced; an unrecognised reason reads as no block), exposed on the order DTO beside #3455’s skip reason for #3482 to render. Not changed: the ambiguous-A2 arm (reported by #2352’s derived A2-A, persisting it would double-count), a claimant with no router wired, and the #3487/#3488/#3455 skip rules all still follow today’s path. -
Routed work is OFFERED to its holder, from both routing sites (#2955):
fulfillment.work.dispatchwas fully built - handler, lane registration,FulfillmentHandshakeService, theassignmentAttemptclaim - and enqueued by nobody, so a routedFulfillmentWorkstayedunsubmittedfor ever and the pack bench, which filters onaccepted, was permanently empty. Both routing sites now enqueue one dispatch job per assigned work:OrderIngestionService’s intercept (#2396) and thefulfillment.work.routehandler (#2408). Four properties are load-bearing. (1) The decision is derived ONCE and the mapping happens twice -deriveFulfillmentDispatchEnqueueIntents(libs/core/src/fulfillment) returns a neutral intent, because the two hosts reach DIFFERENT ports with incompatible request shapes (SyncJobQueuePort.enqueuein core,JobEnqueuePort.enqueueJobin the worker); a shared request object would have forced one host onto the other’s port. (2)connectionIdis the work’s OWNassignedConnectionId, never a synthetic id - #2609’s defect was precisely a shared scope collapsing per-scope lane accounting installation-wide, and a work with no holder is SKIPPED and warned rather than dispatched under a placeholder. (3) The dedupe key isfulfillment:dispatch:{workId}, its own namespace - never the handshake’swork:{workId}:{attempt}(#2399), which is the key the EXECUTOR dedupes on; sharing it would make an OL-side re-enqueue and a holder-side replay indistinguishable. Becausesync_jobs.idempotencyKeyis globally unique and TTL-less, a second dispatch for the same work row is silently swallowed - which is correct while nothing re-requests, and the day a re-source or re-request path lands the key MUST gain a generation component (recorded as a constraint against #2395 onbuildFulfillmentDispatchDedupeKey). (4) The enqueue is per intent with a per-intent catch, and the producer never throws -enqueueBulkdisclaims atomicity and is a sequential loop, so one error log naming every work would be a false statement about the ones that DID dispatch; and on the ingestion path the producer runs insideinterceptFulfillmentRouting’s fail-open try, whose catch returns{held: false}- an escaping throw would mirror to every destination an order whose work rows are already committed, i.e. fulfil it twice. The work-route branch became reachable with #3485, whose reroute sweep isfulfillment.work.route’s first producer; it was wired and tested before that on the #2400 posture - a route handler that silently fails to dispatch is strictly worse than an unexercised branch. -
The router has a dry run, and a draft ruleset can be compared with the saved one (#2954):
POST /connections/:connectionId/sourcing-rules/dry-run(admin-only) isFulfillmentRouterPort.evaluate()’s first caller - it takes an ingested order or a hand-built basket and returns the router’sRoutingEvaluation, callingevaluate()and neverroute(), so no decision row, work, hold or idempotency key can result (routing_decisionsandfulfillment_worksrow counts are asserted unchanged against real Postgres). Four properties are load-bearing. (1) A draft is an unsaved ruleset reached only through the guard-onlyDraftRuleEvaluatorsub-capability (libs/oms), not through a parameter on core’sFulfillmentRouterPort- rules are@openlinker/omsvocabulary and REVIEW H7 keeps them opaque across that port;route()has no rule parameter at all. Each draft rule passes throughcoerceRoutingRule, the function saved rules pass through, so a draft that could not be saved cannot be evaluated either, and the purediffRoutingEvaluationsnames the lines it would place differently. (2) The order is reduced to the ADR-062 allowlist by one shared pureprojectOrderForRouting(libs/core/src/orders/domain), which ingestion and thefulfillment.work.routehandler now call too - it existed as two private copies and the dry run would have been a third, and three copies of an allowlist is how one starts leaking. A basket accepts only country, postcode, lines and a delivery method, so there is no field to put buyer data in. (3) The router’s words are rendered verbatim: an unfulfillable line’sreasonand each explanation step’sdisplayLabelreach the panel untranslated, so the frontend owns no reason or rule vocabulary. (4) The compare control exists only when the page supplies a draft, and nothing on the rules page produces one yet - every edit there saves at once, so an “unsaved ruleset” has no source (the mockup’s own open question). -
The routing INTENT row, and the live partial-unique index (#2394, ADR-054 R1, DESIGN §5.3):
routing_decisionsis OpenLinker’s own record that it is ABOUT to ask a router where an order is fulfilled from, persisted BEFORE the committingroute()call - REVIEW C2’s rule that persisted evidence must land before the boundary it protects, an ordering a lock alone cannot supply (a lock is lost on process death, on a TTL expiry or on a Redis blip, and the peer that acquires it next has no way to learn aroute()call is already in flight). The shipped #2047 invoicing guard is the shape being copied. Six properties are load-bearing. (1) The guard isUNIQUE ("orderId") WHERE "state" = 'live', and the predicate is exactly that wide. Unconditional, it forbids any second decision for an order ever, breaking the legitimate re-route DESIGN §5.4 requires (short_picked+releaseShortfallre-entersroute()with the rejecter blocked); keyed("orderId", "routerConnectionId"), it permits two routers to hold two live decisions for one order, which is precisely the double-ship #2395’s guard must refuse regardless of router identity - and a double-ship is a physical, unrecoverable event. A terminal row must LEAVE the index so a fresh decision can claim, which is why the mutable-predicate warning onshipment.orm-entity.tsdoes NOT govern here: rows leaving the index on terminalisation is the MECHANISM, not a side effect, and the governing precedents areUQ_order_changes_open_targetandUQ_reservations_active_line, both keying uniqueness on an open-state predicate exactly as this does. It is necessary, not sufficient - DESIGN §5.3 also requires refusing when non-cancelled WORK exists, a different set since acommitteddecision with live work leaves the index free, and that half is #2395’s. (2) Uniqueness is the INDEX, never an application check -claimIntentinserts and translates23505intoRoutingDecisionAlreadyLiveError, because at READ COMMITTED a plainSELECTtakes no locks and the conflicting row is a phantom, so a read-then-act guard enforces nothing (#2392 had to lock the PARENT row for its hold cap for the same reason). The match is on SQLSTATE and constraint name, the #2392 rule: this table carries two unique constraints, so catching every23505would report a PK collision as “a live decision already exists”. It throws rather than returning the incumbent, unlike theexchange_ratesinsertIfAbsentshape - there any winner will do, whereas here a live decision existing IS the refusal, and handing back the winner would invite a caller to route against another router’s intent. (3) Writer discipline, with nosave(decision)at all - the row is inserted once and mutated exactly once by one narrow conditional UPDATE (WHERE "id" = :id AND "state" = 'live'), so the four terminal-owned columns cannot be null-then-reset by an unrelated write;stateis not accepted at create (a claim is live by definition, and accepting it would allow inserting an already-terminal row) and there is noexpectedStateparameter, becauseliveis the only legal precondition and a choice that does not exist must not be offered. (4)TerminaliseRoutingDecisionInputis a discriminated union whose arms are DERIVED fromRoutingDecisionState, so{state: 'committed', abandonReason: …}does not type-check and a fourth terminal state gets an arm automatically rather than silently becoming unwritable through the port. The asymmetry is deliberate:abandonReasonis refused on thecommittedarm whilerouterDecisionRefis legal on BOTH, since #2393’splan-not-conservingdescribes a router that answered with aRoutingPlan- and therefore with adecisionId- which OpenLinker then refused. (5) No foreign keys.orderIdandrouterConnectionIdare cross-aggregate references by value (theorder_changes/refund_records/fulfillment_works.orderIdprecedent): an INTENT record must survive the thing it decided about, so a deleted connection must not erase the audit of what it decided and a re-ingested order must not cascade away the decision history.IDX_routing_decisions_orderis unconditional and NOT redundant with the partial index - the history read (“every decision for this order, whatever its state”) cannot use an index restricted to live rows. (6) The route idempotency key is DERIVED, never stored -deriveRouteIdempotencyKeyis a pure function of the row’s own immutable id, so a retry of a crashed route re-derives a byte-identical key while a re-route is a new row and therefore a new key, which is correct because a genuinely new decision must not dedup against the previous one (the #2039reconcileIdlesson). A stored column can be written with a value that is not the derivation; deriving makes “the key is a function of the row” true by construction.routing_decisionsalso joinsfulfillment-work-migration-parity.int-spec.tsrather than getting a sibling spec - that file already runs the full migration chain once per integration run, and itsindexdefcomparison is exactly what catches a partial-unique predicate that differs between the migration and the entity, which would let this guard hold in production and silently not in tests.
27. Shipment lines
Section titled “27. Shipment lines”DECISION-oms-fulfilment-grain option C; split out of #2402, which declined it.
- Line-grain shipment records so an order’s shipped / delivered quantities are DERIVABLE (#2727):
shipment_lines(counters) plusshipment_line_events(an append-only act ledger). #2402 putfulfillmentWorkIdonshipmentsbecause the work→shipment relation is 1:N and the FK belongs on the many side; this table expresses a DIFFERENT relation — one shipment carries units from one or more order lines, possibly across more than one order — and carries nofulfillmentWorkIdat all. orderIdis part of the line’s IDENTITY, not a payload column.shipments.orderIdis the order the parcel was dispatched for; a CONSOLIDATED parcel carries units from more than one order, which is the entire reason option C keys on(shipmentId, orderId, lineId). Derive the line’s order from its shipment header instead and a line belonging to order B inside a parcel headed by order A becomes unrepresentable — order B’s derived shipped quantity is permanently zero and nothing says so. Every writer setsline.orderId = shipment.orderIdtoday; the column is what lets that stop being true without a migration.- The counters are a FOLD; the acts are authoritative.
foldCountersis a TOTAL recompute rather than an increment (thereservations/inventory_items.olReservedQuantityshape), which is also what makes the runtime path CONVERGENT over whatever the backfill migration wrote: if the two ever disagreed on the acts, the next fold adds the missing ones and the counters follow. - The backfill is EVENT-shaped, and that is the whole issue rather than a style note. Order O, line L, quantity 2;
S1dispatched then cancelled,S2the re-issue. A SNAPSHOT backfill writesS1.shipped = 2andS2.shipped = 2— order-level 4, permanently and unfixably, because both rows claim to be shipments of the same units and nothing distinguishes a correction from a second shipment. The event backfill writesS1: ship(2), cancel(2)(net 0) andS2: ship(2)(net 2). Thecancelact IS that distinction, and it exists only because the pass walks each shipment’s history instead of restating its end state. It is lossy and named as such — attributing each shipment the line’s FULL ordered quantity is a guess for a genuinely multi-package order, since OL never recorded the split — but a later cancel-and-reissue does not COMPOUND the error.shipment-lines-backfill.int-spec.tsasserts the net AND computes the snapshot-shaped sum alongside it, so the test genuinely fails against a snapshot implementation rather than merely passing against this one. UQ_shipment_line_events_line_kind_occurredcarries the INSTANT, and that is what makes a reused shipment row correct. A(line, kind)key looks right on the reasoning that a shipment ships once — butShipmentDispatchServicepersistsfailedin itsgenerateLabelcatch and a retry re-enters on the SAMEshipment.id, sofailed → generated → dispatchedis reachable on one row. Under that key a line that had emittedship+cancelcould never record its successful re-dispatch, folding to a net shipped of 0 for a parcel that really shipped. With the instant in the key a repeated reconcile re-derives the identical tuple and inserts nothing (idempotent under a bareON CONFLICT DO NOTHING, with no sequence counter and therefore no lock, unlike the returns(returnLineId, seq)shape), while a genuine re-dispatch carries a newdispatchedAtand correctly emits a secondship.occurredAtis the SHIPMENT’s own instant falling back to its immutablecreatedAt— neverupdatedAt, which moves on every write and, being a key column, would mint a fresh duplicate act on every reconcile.CHK_shipment_lines_capacitydeliberately OMITS"shippedQuantity" <= "quantity". Two ordinary paths violate it: re-ingestion rewritesorderSnapshotwholesale (a frozenquantityof 2 meets aship(5)act), and the dispatch retry above folds toshipped = 4, cancelled = 2on a line of 2. Either would raise inside the reconcile’s best-effort catch and silently stop the read model converging — strictly worse than not having the clause.quantityis PROVENANCE, not a bound; the bound that matters is"cancelledQuantity" <= "shippedQuantity", which makes the NET shipped quantity non-negative by construction, and that net is the number every derivation reads. The parity spec asserts the omission POSITIVELY, because every other clause assertion stops the constraint being weakened and only a negative one stops it being completed.- The rollup precedence fix has TWO separable halves (#2727, obligation 2). The old rule was
any delivered ⇒ delivered, so an order with one parcel delivered and one still in transit reported as fully delivered. The status half makes an in-progress shipment OUTRANK a delivered sibling — no line data needed, so it fixes the shipment-grain case on every install including one that never runs the backfill. The quantity half takes an OPTIONALFulfillmentQuantityCoverageand demotes a fully-terminal delivered order whose lines are only partly covered — the case the status half cannot see, because every shipment really is finished. The numerator OVER-attributes, and the rule survives that because it only ever DEMOTES: an over-estimate that is still short proves the truth is shorter, so a false demotion is impossible and only a missed one. Absent coverage means “no line data”, never “zero delivered” — reading it as zero would demote every pre-backfill order and quietly empty the delivered bucket.dispatchedis the honest approximation becauseFulfillmentRollupStatehas nopartial; adding one would need this union’s SQL twins, the FEderiveFulfillmentand every label map, so it is named as a deliberate omission. The FE twin mirrors the STATUS half only and says so — the browser has no quantity input, so the asymmetry is intended rather than drift. - ONE writer seam, and it is not unconditional.
OrderFulfillmentProjectionService.recomputeis already called from all eight shipment-mutation sites, so no mutation service changed;reconciletakes the shipments that method ALREADY loaded rather than re-reading them, becauserecomputeruns once per shipment inside the two status-sync loops and areconcile(orderId)that re-queried would beO(S² × I)upserts for a page touching S shipments of one order. Three of those sites sit behind aif (patch.status)changed-patch gate, so a tracking-only backfill (#1947) or alabelPdfRefwrite creates no missing line rows — the SAME gate that produced #2402’s permanently-unlinkable branch-1 row, recorded rather than asserted away. The dispatch path, where a line first needs to exist, always callsrecompute. - Direction is scoped by JOIN, never by a copied column (#2373 / ADR-060):
directionlives onshipmentsand stays there, so every read joins and filters there withdirectiona REQUIRED parameter carrying no default — theShipmentRepositoryPortrule verbatim, since a default is a silent decline and reading an arriving return as a dispatch is the #2645 cross-body defect. Denormalising it onto the line would create a second source of truth for the one column #2373 exists to BE. - The #2402 unlinkable branch-1 gap does NOT become reachable here, by construction. This read model keys on
(shipmentId, orderId, lineId)and joins onorderId; it never consultsfulfillmentWorkId, so an unlinked shipment contributes its quantities exactly like a linked one. Keying the derivation on the WORK would have inherited the gap wholesale — every such shipment would silently contribute zero. The gap remains open and remains #2402’s. - Not shipped in this slice: the operator-facing FE line panel (issue obligation 4, not one of the six acceptance criteria). The read model is not written-and-never-read regardless — the rollup consumes the derived coverage on every recompute.
28. The pack bench and the Assign Packing Work board
Section titled “28. The pack bench and the Assign Packing Work board”See ADR-071 for why the bench has no principal of its own and ADR-074 for why an assignment is advisory rather than exclusive.
Two operator surfaces over one aggregate. The bench (/bench, apps/api/src/bench,
apps/web/src/features/bench) is what a packer stands at: a rail of the work they may pack, one
parcel open at a time, and a scan field. The board (/fulfillment,
apps/api/src/fulfillment/http, apps/web/src/features/fulfillment) is what a supervisor uses to
put a parcel in front of a named packer before they reach for it. Both read FulfillmentWork
(§ 26) and neither owns state of its own.
- The bench has no principal, so the ROUTES are what narrow it (ADR-071). There is no station
token, no PIN and no badge; a packer is an ordinary signed-in user, and the only thing making
their session narrower than any other is the
packerrole plus the@Roles('admin', 'operator', 'packer')decorators naming it. That is whyROLE_PERMISSIONS.packerheld nothing at all until #3424, and why the one permission it holds now (bench:write) exists solely because the bench’s two write CONTROLS are rendered client-side and a client-side gate needs something to read. Before that,Claim this parcelandTake next taskgated onorders:writeand so never rendered for the one role that would press them, while the API behind them accepted a packer perfectly well - the UI hiding what the route allowed, which is the same class of defect as a UI offering what the route refuses. apps/api/src/auth/packer-exclusion.spec.tsis the authority for what a packer can reach, route by route, and for what each projection carries. ADR-062 is not, and must not be cited as though it were: its subject is what crosses to a PLUGIN (RoutingInput,HostServices, the router port), not what OpenLinker’s own HTTP surfaces disclose to a signed-in operator. Three places in this stack made that citation and one of them used it to assert “no total, no price” about a projection that had carried both since #3409 (ADR-062 now states its own scope).- Two PII disclosures are deliberate and each has a reason on the surface it appears. The
bench’s parcel projection carries the buyer’s NAME, the order TOTAL and currency, the CARRIER and
the SHIP-BY deadline - every one of them printed on the documents the packer is about to put in
the box, so withholding them from the screen protected nothing while costing the packer the check
that catches a wrong parcel. The board carries the buyer’s name masked server-side (
A. Kowalska), which is what lets a supervisor tell two parcels apart without handing a temp the customer register; the un-masked value is never resolved in the DTO mapper, so it does not exist on that path for a later change to leak. Neither surface reaches an address, an email or a phone number. - A product photo is proxied, never linked, and the proxy is a network boundary.
GET /products/:id/images/:indexfetches the picture through OpenLinker, because the stored url is the one the CATALOGUE SYNC used - on every Docker-compose install that is an internal host a browser cannot resolve (measured: 71 of 71 products with images on the demo database). The caller names an INDEX and never a url, which bounds the reachable set to what the operator’s own sync wrote - but the WooCommerce mapper writesi.srcVERBATIM from the shop’s response, so a hostile shop chooses part of that set. HenceisProductImageUrlAllowed: link-local and the cloud metadata hostnames refused on every redirect hop (followed by hand -redirect: 'follow'checks the first url and then goes wherever the shop points), while RFC1918 and loopback stay allowed, because reaching an internal host is the feature rather than the threat. It is deliberately NOTisUrlSsrfSafe, which refuses exactly the ranges this must reach. DNS rebinding is stated as open rather than implied closed. - The box shuts on the system’s own count, and there is no control that closes it (D18). The
last verification closes the parcel inside the same transaction that records it;
apps/api/src/bench/__tests__/no-parcel-commit-route.spec.tsfails the build if a bench route path ever gains aclose/commit/finish/sealverb, and it is extended by naming the exact new path, never by loosening the keyword. - The board is a read-only preview for
viewer(#3736).fulfillment:read(admin, operator, viewer; never packer) opens the board and the task detail;GET /fulfillment/works/:workId, its/shipmentsandGET /users/packersadmitviewer, while the action and assignment writes stay admin + operator. The viewer thereby gains one disclosure - every active packer’s id and username from/users/packers- accepted because the board must resolveassignedToUserIdto a name and the projection is minimal.scripts/check-bench-write-roles.mjschecks the permission against the union of the controller’s@Rolesand againstGET /users/packers; the per-route lists are pinned byfulfillment-work.controller.spec.ts, since a union cannot see one GET narrowed alone. - An assignment is a staffing hint, not a lock (ADR-074). A packer may still claim an assigned
parcel unless a supervisor set
selfServeEligible = false, and “assigned to other packers” is the SUPERVISOR’s view - a packer sees their own work and the unassigned pool, and nothing else. - An order OpenLinker packs itself is shown on
/ordersas a separate, non-partitioning field, and the pack bench never movesFulfillmentWork.status(#3482):handledInOpenLinker { stage, workCount }on the orders list and detail reads is derived from the order’s live works by the purestageOfFulfillmentWork/summarizeOrderWork(fulfillment), one batchedIFulfillmentWorkQueryService.summarizeWorkForOrdersper page. Four properties are load-bearing. (1) A bench-closed parcel ispackedbyparcelClosedAt, never bystatus: the bench records its progress in its own columns (parcelClosedAt,completedAt,packedByUserId) and a shut parcel keepsstatus = 'open', so a rule readingstatusalone reported “To pack” for a packed parcel - a defect the first version shipped, because its unit tests fed synthetic statuses.fulfillment-order-work-summary.int-spec.tstherefore asserts the bench’s real shape against Postgres. (2) Absence is “no live work”, never “not routed”: an order never routed, one whose work was cancelled and one routed to nobody all read as absent, and a failed read degrades to absent rather than to a claim. (3) A split order reports its EARLIEST stage, since it is only as far along as its least-advanced parcel;cancelled,incompleteand a status outside the union take no part (the last returnsnull, neverundefined, becausestatusis a plaintextcolumn cast at the repository boundary andundefinedwould pass the!== nullfilter and inflateworkCount). (4) It is NOT a health bucket (D22): the KPI cards still sum to “All orders”.OrderWorkStageValuesis mirrored inapps/weband held identical, in value and order and with one copy entry per stage, byscripts/check-handled-in-openlinker-mirror.mjsunderpnpm check:invariants(which also holdsWaitingFulfillmentBlockReasonValues); an unknown stage renders nothing, which was safe but silent before the guard. The “Waiting: …” badge reads the persistedfulfillmentBlockReason; the frontend types it with mirrors ofFulfillmentBlockReasonValuesandFulfillmentRoutingSkipReasonValues, held identical to core byscripts/check-fulfillment-reason-mirror.mjs, which also refuses aWAITING_REASON_COPYkey that is not a block reason. Part 2 (#3799) adds four things. (5) The health partition moves a shipped handled order tosynced: such an order has nosyncStatusrows, so before this it stayed the residualawaiting_dispatchfor ever, even after delivery. It now staysawaiting_dispatchwhile it is on the bench and counts assyncedoncefulfillmentStateisdispatched/delivered, in both twins (FEderiveOrderHealth, SQLIS_SYNCED_HEALTH, which feedscountByHealth,applyHealthFilterandHEALTH_ORDINAL). The handled condition is required, so a shipped order with nothing configured keeps its old bucket and a non-OMS install sees no change; there is still no sixth bucket (D22). The FE twin reads the best-efforthandledInOpenLinkerfield, so when that read fails a row can say “Awaiting dispatch” while the card counts it synced. The SQL twin readsfulfillmentStatethroughCOALESCE(..., 'not-shipped'): the column is NULL until the first shipment mutation - exactly the state of an order on the bench - and a bareIN (...)madeTRUE AND NULLNULL, so such an order counted in neithersyncednor (throughNOT (...))awaiting_dispatch, and the buckets stopped summing to the total. The two twins are tied by a shared fixture table,ORDER_HEALTH_FIXTURES(@openlinker/core/orders/testing, including the NULL row): the integration spec seeds every row and asserts the SQL bucket, the FE test runs the mirror table throughderiveOrderHealth, andcheck-handled-in-openlinker-mirror.mjsholds the two tables to the same rows. (6) Two ADR-036 raw reads fromordersintofulfillment_works(IS_HANDLED_IN_OPENLINKER,IS_ON_PACK_BENCH): read-only, parameter-freeEXISTSsubqueries onorderId(served byIDX_fulfillment_works_grouping), justified by the health partition and the on-bench filter/count. Their status lists are built fromPackingFlowFulfillmentWorkStatusValues/UnpackedFulfillmentWorkStatusValues(fulfillment), the same listsstageOfFulfillmentWorkis pinned to by its spec, so a count and a row cannot disagree about which works are live. (7) “Waiting in OpenLinker” and “On pack bench” are axes besidehealth, never buckets:?waitingInOpenLinker(the persistedfulfillmentBlockReasoninWaitingFulfillmentBlockReasonValues, oldest age fromfulfillmentBlockedAt, which dates from the start of the hold rather than of the waiting reason) and?onPackBench(a live work whose box is not shut), each a filter and a summary count read from one predicate. (8) “Packing” and the packer come from the bench’s own facts: a non-voidedfulfillment_work_verificationsrow on an open box readspacking, andassignment/assignedToUserIdreport who packs it (mixedfor a split order assigned to different people); the frontend resolves the name from the packers roster, fetched only when a row needs it, and shows “Assigned” rather than a raw id when it cannot. - Live coverage is
apps/e2e/sweep-fulfillment-states.mjs, 34 states across both surfaces, driven against a real stack. It is not wired to CI (e2e.ymlis deliberately manual), so it is a gate somebody runs rather than one that runs itself.
29. Paginated reads: the total is a second stage
Section titled “29. Paginated reads: the total is a second stage”See docs/engineering-standards.md § When A Paginated Total Is Expensive for the repository shape, and docs/frontend-architecture.md § Paginated Totals As A Second Stage for the rendering rule.
Not a bounded context - a cross-cutting READ convention, recorded here because #2943 widened a public API contract across four surfaces.
The problem is asymmetric and only one half was ever bounded. A paged read stops after its LIMIT; the COUNT beside it cannot stop at all. Under a predicate no plain index serves - a jsonb containment (syncStatus @> ...), a ->> extraction, an ILIKE - the count scans the whole table however small the page is. Measured on the #2843 lab stand at a million order_records: GET /orders?health=needs_attention took 184 ms; the count measured separately took 179 ms and the page alone 8.9 ms. Three independently measured routes, not one request decomposed - the two halves are additive to within noise, which is the honest statement and the one the report makes. The plan is the honest version - Limit ... Index Scan Backward, 0.286 ms, 76 buffer pages, against a Parallel Seq Scan, 175.8 ms, 78 436 pages (~613 MB through the buffer pool), so the cost is felt application-wide rather than on one page.
Five repositories therefore answer three ways - findMany (rows and total), findManyRows (the page alone) and countMany (the total alone, taking no pagination and no sort): order-record, offer-mapping, product, product-variant, customer-projection. A sibling method pair rather than an optional flag, because a flag forces total to number | undefined on a result five ports and a dozen call sites already read as number, so every ?? 0 turns “not asked for” into a rendered zero - the exact failure the split exists to prevent, one layer down. OfferMappingRepository had already grown that shape (skipTotal answering the sentinel total: -1) and #2944 removes it rather than propagating it.
Over HTTP the contract widens rather than changes. Six list routes accept ?withTotal=false (a shared PaginatedReadQueryDto inherited by five query DTOs, one of which serves two routes - inherited, never copied) and six GET .../count siblings answer the number alone (/orders, /customers, /products, /products/:productId/variants, /variants/search, /listings). Each count DTO is an OmitType of its list DTO, so the two filter surfaces cannot drift. A response OMITS total when it was not asked for - never 0: absence and “none matched” are different claims, and the whole chain fails if any link substitutes one for the other. Every existing caller is untouched, because withTotal defaults to on.
Every other paged read is deliberately untouched. They count over indexed equality, where the total is nearly free, and adopting there would add a loading state to pages that never needed one.
One correction worth carrying forward, because it was believed and written down before it was checked. The first draft justified keeping findMany on a single getManyAndCount() by claiming TypeORM infers the total with no count query when a page comes back short. Read against the pinned typeorm@0.3.17, that is false: getManyAndCount awaits executeEntitiesAndRawResults and then executeCountQuery, unconditionally and sequentially, and the lazyCount identifier does not exist in that version. The count always runs, which is the argument FOR the split rather than against it. findMany keeps its existing shape for a different and sufficient reason - ?withTotal=true stays byte-identical to its pre-#2944 self.
Capability Abstractions (Business Roles)
Section titled “Capability Abstractions (Business Roles)”See ADR-002 for the decision rationale.
Instead of coding directly against specific systems (e.g., PrestaShop, Allegro), the core domain depends on business capability abstractions (ports). This allows:
- Flexibility: Switch implementations without changing core logic
- Testability: Easy to mock for testing
- Clarity: Business intent is explicit in code
InventoryMasterPort
Section titled “InventoryMasterPort”Purpose: Single source of truth for inventory/stock levels.
Interface:
interface InventoryMasterPort { /** * Get current inventory for a product */ getInventory(productId: string, locationId?: string): Promise<Inventory>;
/** * Adjust inventory (increase or decrease). * * The command may carry an optional `idempotencyKey` + closed `reason`, and * the result an optional `adjustmentOutcome` (#2368) — see § Inventory. */ adjustInventory(adjustment: InventoryAdjustment): Promise<InventoryAdjustmentResult>;
/** * Reserve inventory for an order * * @deprecated (#2315, ADR-061) — see note below */ reserveInventory(productId: string, quantity: number, orderId: string): Promise<void>;
/** * Release reserved inventory * * @deprecated (#2315, ADR-061) — see note below */ releaseInventory(productId: string, quantity: number, orderId: string): Promise<void>;
/** * Get available quantity (total - reserved) */ getAvailableQuantity(productId: string, locationId?: string): Promise<number>;}Current Implementations: PrestashopInventoryMasterAdapter, WooCommerceInventoryMasterAdapter
Deprecated surface (ADR-061). reserveInventory / releaseInventory are deprecated in place (#2315). No shipped master exposes a hold primitive; both adapters throw NotSupported and nothing calls them. Reservations are OL’s own concern — the advisory reservation ledger (hold, never a decrement, with a mandatory expiresAt) plus the AvailabilityAuthority capability for ATP. The residual need to push a hold to a master that models one returns later as an optional MasterReservationWriter sub-capability, deferred until an adapter exists that can implement it. The methods are not removed: the port is a published contract that out-of-tree plugins compile against, so removal waits for a contract-major cycle (ANALYSIS-1032 §5). Deprecate-in-place applies to a published contract that plugins implement or call, not to every barrel export: #2293 deleted the pure helpers parseIsPrimaryInvoicing / selectPrimaryInvoicingConnection outright, because they only coerced OL’s own Connection.config.invoicing.isPrimary shape, which no adapter has a legitimate reason to read, whereas removing a port method breaks every implementer.
Future Implementations:
OpenLinkerInventoryMasterAdapter(OpenLinker’s own inventory system)ShopifyInventoryMasterAdapter
ProductMasterPort
Section titled “ProductMasterPort”Purpose: Single source of truth for product catalog. Manages product data, variants, attributes, and categories.
Interface:
interface ProductMasterPort { /** * Get product by ID */ getProduct(productId: string): Promise<Product>;
/** * Get products with filters */ getProducts(filters?: ProductFilters): Promise<Product[]>;
/** * Create a new product */ createProduct(product: ProductCreate): Promise<Product>;
/** * Update an existing product */ updateProduct(productId: string, product: ProductUpdate): Promise<Product>;
/** * Delete a product */ deleteProduct(productId: string): Promise<void>;
/** * Get product variants */ getProductVariants(productId: string): Promise<ProductVariant[]>;
/** * Create or update product variant */ upsertProductVariant(productId: string, variant: ProductVariantCreate): Promise<ProductVariant>;
/** * Get product categories */ getProductCategories(productId: string): Promise<Category[]>;
/** * Assign product to categories */ assignCategories(productId: string, categoryIds: string[]): Promise<void>;
/** * Search products by query */ searchProducts(query: string, filters?: ProductFilters): Promise<Product[]>;}Current Implementations: PrestashopProductMasterAdapter, WooCommerceProductMasterAdapter
Future Implementations:
OpenLinkerProductMasterAdapter(OpenLinker’s own product catalog system)ShopifyProductMasterAdapter
Usage Example:
@Injectable()export class ProductSyncService { constructor( private readonly productMaster: ProductMasterPort, // ✅ Port interface ) {}
async syncProductToMarketplace(productId: string, marketplaceId: string) { // Get product from master const product = await this.productMaster.getProduct(productId);
// Map to marketplace format and publish // ... }}OrderProcessorManagerPort
Section titled “OrderProcessorManagerPort”Purpose: Create orders on a destination shop. The base port carries only createOrder — the single method every order destination must implement. Post-create status/tracking writes, lifecycle reads, and option discovery are split into composable sub-capabilities (mirroring OfferManagerPort); a full OL-owned order-lifecycle state machine (updateOrderStatus / cancelOrder / processReturn / getOrders as authoritative operations) is deferred — see #1032.
Interface:
interface OrderProcessorManagerPort { /** * Create a new order on the destination shop. * * Returns the destination-native external order id (OrderRef.orderId), * never an internal OpenLinker id (#909). Idempotency and the * external↔internal mapping write are owned by OrderSyncService under a * per-(order, destination) lock — the adapter creates unconditionally. * Lines MUST be priced at the buyer-paid source price (#895, ADR-014). */ createOrder(order: OrderCreate): Promise<OrderRef>;}Sub-capabilities (in libs/core/src/orders/domain/ports/capabilities/):
Each is an independent interface + co-located is{Capability}(adapter) type guard. Destinations declare what they support via implements OrderProcessorManagerPort, OrderFulfillmentUpdater, …; call sites resolve the destination adapter via getCapabilityAdapter<OrderProcessorManagerPort>(connectionId, 'OrderProcessorManager'), narrow with the guard, and degrade gracefully (skip) when a destination doesn’t implement it.
| Capability | Method(s) | Notes |
|---|---|---|
OrderFulfillmentUpdater | updateFulfillment({ externalOrderId, status, trackingNumber? }) | Push a post-create status + tracking update to the destination order (#837). PrestaShop maps the neutral OrderStatus to its native state. |
OrderStatusWriteback | write(event: OrderLifecycleEvent) | Relay-based, source-bound order-status round-trip — push a status change back to the originating marketplace (#1157, ADR-027). |
The relay excludes the event’s AUTHOR, not just its origin (#2401, DESIGN §5.5). OrderLifecycleRelayInput carries an optional authoredByConnectionId that is UNIONED with originConnectionId into one exclusion set; absent reproduces the origin-only set exactly, so no pre-existing caller changed behaviour. The two are genuinely different facts, because originConnectionId is not an authorship claim in practice - ShipmentDispatchNotificationService passes the CARRIER and the automation executor passes the sentinel 'openlinker:automation', both non-participants chosen precisely to exclude nothing. The case that forced the split is a fulfilment work dispatched by a 3PL that is ALSO a participant of the order: without it, a dispatched relay tells the 3PL that just shipped the parcel that the parcel shipped. The new FulfillmentDispatchRelayService (orders) uses both - a 'openlinker:fulfillment' sentinel as origin, and the work’s holder as the author - which is what makes the field load-bearing in production rather than only in specs.
delivered and in-progress join the relay’s vocabulary (#3526, epic #3506). OrderLifecycleEventTypeValues widens from ['dispatched', 'cancelled'] to add both. delivered is FIRED, from ShipmentStatusSyncService on a shipment’s own carrier-tracking transition into delivered - the same method that already relays the waybill, but gated independently: it fires on snapshot.status !== shipment.status && snapshot.status === 'delivered' alone, regardless of whether a NEW tracking number is present in the same poll, because a shipment can be delivered with its waybill already known. It carries no claim/release cycle the way Shipment.waybillRelayedAt does for the waybill relay’s non-idempotent POST /shipments - a delivered-equivalent status write is idempotent on every shipped OrderStatusWriteback adapter (writing a status a participant already holds is a no-op there). Since #3506 (G02-7) it is RETRYABLE, not one-shot. As first shipped, a rejected delivered relay was only logged, and because SCAN_STATUSES excludes delivered the row was never visited again - a PrestaShop destination that rejected the write stayed “Shipped” until somebody reset the database. Three shipments columns now carry the outcome (deliveredRelayedAt, deliveredRelayFailureCount, deliveredRelayLastFailureAt, migration 1919000000000, plain TIMESTAMP like the rest of the table; existing delivered rows are backfilled as relayed): a relay that reached every participant that could take it (a structural unsupported counts) stamps deliveredRelayedAt - on the transition path BEFORE the status write lands, so a first-time success never becomes owed - while a rejected, an adapter-unresolved or a throw counts a failure. After its scan, sync() runs a bounded re-drive pass over the connection’s provider-backed outbound delivered rows still owed the relay (frontier-as-query, 25 per call, 10-minute back-off, at most 48 attempts and deliveries under a week old; the attempt that exhausts the bound logs delivered_relay_given_up once), so an operator who fixes the cause (e.g. adds the missing order-state mapping) sees the relay apply on the next tick. The re-drive is safe for the same idempotency reason, which also bounds the one accepted race below. The one accepted, narrow limitation - two callers racing on the SAME shipment (a poll tick and a concurrent webhook, both reading the pre-patch row before either persists) could both relay once - is bounded by that same idempotency argument, and is stated in the method’s own docblock rather than hidden. in-progress (work accepted / picking) is VOCABULARY-ONLY in this slice - every adapter maps or declines it, but nothing fires it yet, the same “seam ships ahead of its consumer” posture IFulfillmentProgressService.record’s own header states for its 'picked' event. Per-adapter mapping is deliberately asymmetric rather than uniform: PrestaShop maps both onto its own native OrderStatus vocabulary ('delivered' / 'processing') through the existing shop-configured order_state_mappings seam dispatched already uses - an unresolvable mapping is rejected (operator-actionable), never unsupported, because the shop’s own vocabulary already has a slot for it. Without an operator mapping, 'delivered' resolves from the shop’s own catalogue since #3506 (G02-7): PrestaShop has no delivered flag (the delivered field the adapter used to read does not exist, and the real delivery column is 1 on “Processing in progress”, “Shipped” AND “Delivered”, the last two with identical flags), so deriveOrderState reads delivery from a shipped state’s LABEL (DELIVERED_STEMS, past-participle stems in the languages PrestaShop ships, refused when the label carries a negation or “partially” word). A default PrestaShop 9 shop therefore moves into its own state 5 “Delivered”, state 4 is never reported as delivered, and the inbound feed and the branch-1 fulfilment projection (which now share deriveOrderState) read state 5 as delivered instead of dispatched. Reading PS_OS_DELIVERED through the configurations resource was considered and deferred: it adds a WebService read to every catalogue load and needs a configurations permission the webservice key is not guaranteed to hold. WooCommerce maps in-progress onto its native processing status but DECLINES delivered - WC_ORDER_STATUS_MAP maps both 'shipped' and 'delivered' onto the single WC status 'completed', so the platform genuinely cannot express delivery as distinct from dispatch, and applying it would either no-op or falsely claim a distinction WC’s vocabulary does not have. Allegro and Erli DECLINE both: Allegro’s only verified fulfillment-status members are SENT/CANCELLED (needs-sandbox-probe for anything else), and Erli’s order-status enum (pending | purchased | cancelled | returned) has no delivered member and no distinct status to push for “processing” (already the ordinary purchased state). Every decline reports unsupported, matching the epic’s stated product decision: “a platform that cannot show a state declines it as no-capability; that is never an error.”
Every adapter decline is now reported with a REASON, not just a bare unsupported (#3526). OrderLifecycleRelayTargetResult.unsupportedReason used to be populated only by the RELAY’s own two structural/transient conditions (no capability at all; adapter unresolved), while an in-tree adapter’s own per-event decline (its default: arm, or an explicit case like WooCommerce’s delivered) passed through with no reason at all - writeToTarget now defaults EVERY unsupported outcome to unsupportedReason: 'no-capability' when the adapter supplied none, because “this platform cannot show this state” is the same fact whether the capability is entirely absent or merely declines one event kind. Verified behaviour-preserving against every downstream consumer (FulfillmentDispatchRelayService.describeTransientFailure, ShipmentStatusSyncService’s transientlyUnreached filter, ShipmentDispatchNotificationService.resolveSourceOutcome): all three test specifically for === 'adapter-unresolved', and both undefined and 'no-capability' already fell into their “not transient” branch, so the widening changes what is REPORTED without changing what is RETRIED.
A router-filtered destination receives no relay, and that is deliberate, not an oversight: under a fulfilment router the OrderSyncService fan-out is filtered by destinationConnectionIds, so a filtered-out destination gets no syncStatus[] entry, is never an order participant, and no externalOrderId resolves for it. Nothing load-bearing depends on that entry for routed orders - their fulfillmentState is fed by work-progress-derived shipments instead.
The dispatch relay is claimed at WORK grain (#2401, the build-out that answers #861). fulfillment_works.dispatchRelayedAt is an at-most-once marker claimed by a conditional UPDATE (WHERE "dispatchRelayedAt" IS NULL, #2392) - the #1947 Shipment.waybillRelayedAt shape one grain up - with a releaseDispatchRelay counterpart so a transiently-failed relay hands the slot back. Four properties are load-bearing. (1) The claim is the serialisation point, not the read beside it: the gate reads the work first only so an unknown id claims nothing, and the IS NULL conjunct is what makes exactly one of N concurrent triggers win. (2) The release predicate keys on the TARGET RESULT, never on a thrown error: writeToTarget catches every adapter throw and returns {outcome: 'rejected'}, and relay() throws only from getExternalIds - so the realistic failure (a source waybill POST that 500s) arrives as a rejected target, and a predicate that watched for exceptions would read it as success and burn the marker for ever. rejected and the transient adapter-unresolved release; the structural no-capability does not, since nothing about it is retryable. (3) Zero targets keeps the claim, because [].every(...) is vacuously true and author-exclusion makes an empty fan-out a routine path (a single-participant order whose only participant IS the holder) - releasing there would re-claim on every replay for ever. A mixed result keeps it too: one target applied, and releasing would re-relay that participant. (4) The release frees the slot; it does not re-drive the same event. IFulfillmentProgressService.record burns (workId, idempotencyKey) BEFORE reporting the intent and returns no intent for a duplicate, so recovery comes from the NEXT, differently-keyed progress event. Un-burning that key is disqualified rather than merely declined - it is permanent memory, and re-honouring it would let a replayed vendor event re-move counters, trading a missed relay for corrupted quantities. A reconcile sweep is the genuine closure and is filed separately. Because the relay is fired from orders while the claim lives on a fulfillment row, the seam between them is IFulfillmentRelayGateService - an I*Service on the fulfilment barrel, since FulfillmentWorkRepositoryPort is denied to siblings by pattern - and the direction is forced: firing the relay from fulfillment would mean importing @openlinker/core/orders, which check-no-injection-contracts.mjs and barrel-purity.spec.ts independently forbid. This answers #861 at work grain only: #861’s state is per-DESTINATION, so a relay that succeeds for one participant and fails transiently for another still cannot record the asymmetry and retry stays all-or-nothing - #861 stays open for that half. A second named limitation, corrected and narrowed (#3019): FulfillmentShippedEvent carries no tracking number, so this relay reports only THAT the work dispatched, and burning the claim makes that permanent - a waybill arriving later can never be relayed by THIS path. The limitation is real, but it is not what an earlier revision of this passage claimed: since #2402, ShipmentDispatchService resolves the work and stamps shipments.fulfillmentWorkId (claimFulfillmentWorkLink), so a router-fulfilled ol_managed_carrier work does have a Shipment row, and #1947’s null → value tracking backfill already relays its late waybill through the ordinary role-agnostic relay - the false clause this replaces claimed the opposite. The gap is narrower: it applies only where no Shipment is minted for the work at all (omp_fulfilled - work is observation-only there and OL buys no label - and source_brokered, where the source’s own broker ships it). In both of those, OL is structurally not the physical shipper, so it holds no waybill of its own to relay via this path in the first place - there is nothing this seam is failing to tell anyone today. Closing it anyway would need a contract change to a published port (#2398’s FulfillmentShippedEvent / FulfillmentProgressSnapshot growing a tracking number, so a claim would have something to relay), not a sweep - and with no documented need for it on either topology, that change is not worth making now. Revisit only if a future executor on one of those two topologies actually reports a tracking number and has nowhere to put it.
Late-arriving waybills reach the source too (#1947). A carrier may mint the tracking number after the operator dispatched — InPost/ShipX mints it at confirmation — so the operator-dispatch relay legitimately carries no waybill and the marketplace is only marked sent. ShipmentStatusSyncService’s null → value tracking backfill therefore relays a second dispatched event through the same role-agnostic relay, replacing the last surviving pre-#1168 destination-only push (which resolved record.syncStatus + OrderFulfillmentUpdater — a capability no source adapter implements, so the waybill could previously never reach the marketplace at all; it kept asking the seller to add tracking numbers forever). Two supporting rules:
Shipment.trackingNumberis data;Shipment.waybillRelayedAtis the claim. The field used to be both, which forced a choice between re-driving the source’s non-idempotent waybill POST every tick and permanently losing the number. The marker is claimed conditionally (WHERE "waybillRelayedAt" IS NULL), which is also the serialization point between the status-sync poll and the carrier webhook — two unlocked triggers observing the same transition. Claim-then-release-on-failure mirrors the webhook dedup gate. Generalised per-participant notify state remains #861; retry is still all-or-nothing, so a permanently-broken destination re-drives the source — bounded at the adapter, where the Allegro waybill POST now treats a 409 as already-attached.OrderLifecycleRelayTargetResult.unsupportedReasonsplitsunsupportedinto structuralno-capability(nothing to retry) and transientadapter-unresolved(disabled connection, credential failure — retryable). Without it a source mid-re-auth was recorded as “nothing to notify”, which also letShipmentDispatchNotificationServiceadvance a shipment past its at-most-once gate; that path now leaves it retriable. Deliberately a separate field, not newOrderWritebackOutcomevalues, so a new value cannot fall silently into an existingdefault:arm.
The terminal-status guard suppresses the relay for cancelled/failed only — never delivered, since one poll can carry delivered together with the first waybill and a delivered parcel unambiguously shipped (matching FulfillmentStatusSyncService.isInitialDispatch).
| FulfillmentStatusReader | getFulfillmentStatus({ externalOrderId }) | Read a destination order’s current fulfillment status. |
| DestinationOptionsReader | listCarriers(), listOrderStatuses(), listPaymentMethods() | Discover the destination’s option vocabulary for mapping. |
| SourceOptionsReader | listOrderStatuses(), listDeliveryMethods(), listPaymentMethods() | Discover a source’s option vocabulary for mapping. |
Current Implementations: PrestashopOrderProcessorAdapter, WooCommerceOrderProcessorAdapter
Future Implementations:
OpenLinkerOrderProcessorAdapter(OpenLinker’s own order system)ShopifyOrderProcessorAdapter
OrderSourcePort
Section titled “OrderSourcePort”Purpose: Read-only, cursor-capable ingestion of orders from any source — marketplaces (Allegro event journal) and shops (PrestaShop date_upd watermark). Platform-neutral; the cursor is an opaque adapter-defined string.
Interface:
interface OrderSourcePort { /** * List incremental order feed items (event journal). * `fromCursor` null = start from the beginning; `nextCursor` null = no more pages. */ listOrderFeed(input: OrderFeedInput): Promise<OrderFeedOutput>;
/** * Hydrate a full order by source-native external id. * Returns an IncomingOrder; identifier mapping happens in core services. */ getOrder(input: { externalOrderId: string }): Promise<IncomingOrder>;}Current Implementations: AllegroOrderSourceAdapter, PrestashopOrderSourceAdapter, WooCommerceOrderSourceAdapter
Future Implementations: ShopifyOrderSourceAdapter, ErliOrderSourceAdapter (#993 — plugin skeleton registered at erli.shopapi.v1, see ADR-025)
A source’s own view of fulfilment is polled, recorded and relayed (ADR-012 § Amendment: source-side read-back). The guard-only OrderFulfillmentReadback sub-capability (#3365, Allegro + Shoper) used to have a single on-demand HTTP caller, so a parcel somebody shipped straight in the SOURCE (a merchant packing in the Shoper panel, a warehouse working off Allegro) and the tracking number they added there never reached OpenLinker. SourceFulfillmentStatusSyncService (shipping), driven per source connection by marketplace.sourceFulfillment.statusSync (bulk lane, like its destination twin), records a waybill OL does not already know as a branch-1 row on the source connection (providerShipmentId NULL, born dispatched) and relays dispatched + the waybill to the order’s destinations through the shared relayWaybillUnderClaim (same waybillRelayedAt claim, failure count and give-up bound as #1947). Three echo rules keep it from reading OpenLinker’s own writebacks back: an order OL already tracks a live parcel for is never read; a waybill equal to any tracking number OL holds for the order is ignored; a dispatch with no waybill is ignored. FulfillmentStatusSyncService closes the reverse loop by not relaying a destination’s dispatch back to a source that reported that parcel itself. Shoper’s task defaults ON, Allegro’s OFF (OL_ALLEGRO_SOURCE_FULFILLMENT_STATUS_SYNC_SCHEDULER_ENABLED).
OfferManagerPort
Section titled “OfferManagerPort”Purpose: Base capability contract for marketplace offer/listing management. Split out of the legacy MarketplacePort (#328); the previously-optional methods were extracted into distinct capability interfaces (#337). The base port carries only the single method every marketplace adapter must implement.
Interface:
interface OfferManagerPort { updateOfferQuantity(cmd: UpdateOfferQuantityCommand): Promise<void>;}Sub-capabilities (in libs/core/src/listings/domain/ports/capabilities/):
Each is an independent interface + co-located is{Capability}(adapter) type guard. Adapters declare the capabilities they support via implements OfferManagerPort, OfferLister, OfferCreator, …; call sites narrow via the guard before invoking the method — after the guard TypeScript knows the method is present.
The table below is a curated highlight. The complete, code-synced inventory of all 49 sub-capabilities across every port (with descriptions and guards) lives in
docs/capabilities.md.
| Capability | Method |
|---|---|
OfferLister | listOffers(input) |
OfferEventReader | listOfferEvents(input) |
OfferQuantityBatchUpdater | updateOfferQuantitiesBatch(cmd) |
OfferFieldUpdater | updateOfferFields(cmd), getDescriptionFormat() (optional, ADR-046) |
OfferStockRestorer | restoreStockOnCancellation(targets) |
CategoryBrowser | fetchCategories(parentId?) |
CategoryBarcodeMatcher | matchCategoryByBarcode(barcode) |
OfferCreator | createOffer(cmd) |
OfferStatusReader | getOfferStatus(externalOfferId) |
OfferSmartClassificationReader | getOfferSmartClassification(externalOfferId) |
SellerPoliciesReader | fetchSellerPolicies() |
CatalogProductReader | findProductsByBarcode(input), getProduct(input) |
Current Implementations: AllegroOfferManagerAdapter (implements every capability except OfferQuantityBatchUpdater); ErliOfferManagerAdapter (registered at erli.shopapi.v1, reconciliation-first posture per ADR-025, #984); WooCommerceOfferManagerAdapter (#1498 — base-port-only stock write-back to published shop products: updateOfferQuantity → PUT /products/{id} with manage_stock: true. No offer-creation sub-capabilities; the propagation fan-out reaches it via ShopProduct mappings (second fan-out branch in InventoryPropagateToMarketplacesHandler), not Offer mappings. Write-back defaults OFF on new WC connections and is mutually exclusive with InventoryMaster per connection — the inventory master is never a write-back target).
Future Implementations: ShopifyOfferManagerAdapter, EbayOfferManagerAdapter.
Future Capability Ports
Section titled “Future Capability Ports”- PricingAuthorityPort: Manages pricing rules and catalog pricing. A capability-port-shaped
PricingAuthorityPortis still future work, but its underlying pricing-resolution seam already exists (#1843):readPricingRule/applyPricingRule(libs/core/src/identifier-mapping/domain/types/pricing-rule.types.ts) are pure, marketplace-neutral helpers — mirroring thestockSafetyBufferconfig-coercion precedent (#1844) — that resolve a destination price from the master catalog price via a per-connectionConnection.config.pricingRule({ type: 'passthrough' | 'markup' | 'margin', percent?, rounding?: 'none' | 'nearestWhole' | 'endingIn99' }, JSONB — no schema/migration change).markupis cost-plus (price = base * (1 + percent/100));marginsolves for the price whose margin over the base equalspercent(price = base / (1 - percent/100), guarded forpercent >= 100). BothOfferBuilderServiceandProductPublishBuilderServicecall it as the sole source ofcommand.pricewhenever no explicit per-iteminput.priceis supplied (an explicit price always wins and is never re-priced) — replacing the prior rawproduct.pricepassthrough. A connection with no configured rule is untouched (byte-identical to the pre-#1843 passthrough). Both keys are operator-editable on the connection form:StockAndPricingSection(#2610) rendersconfig.pricingRulealongsideconfig.stockSafetyBuffer(#1844) for every connection, in the shapeRateLimitSection(#1810) established — and becauseapps/webcannot import@openlinker/core(#591), the section’s worked examples run a browser-side mirror of these helpers held identical bypnpm check:invariants. - ShippingProviderManagerPort: Orchestrates shipping and tracking
- PaymentProcessorPort: Handles payment processing
- FiscalizationPort (#1908): hands a sale to a provider that performs or brokers its fiscal registration. Shipped, with eparagony.pl as its first adapter - the contract, its sub-capabilities and their rationale are described once, in § Core Bounded Contexts, 16. Fiscalization.
Capability is open at the registry boundary (#576). The well-known set lives in CoreCapabilityValues as the closed CoreCapability type; adapter metadata (AdapterMetadata.supportedCapabilities), the IntegrationsService resolution methods, and the connection entity’s enabledCapabilities accept CoreCapability | string. Plugin adapters can register new capability names without a core PR — the runtime gate at IntegrationsService.getCapabilityAdapter validates against metadata.supportedCapabilities. The HTTP request DTOs remain strict on CoreCapabilityValues until a runtime-aware DTO validator follow-up lands.
Refund and fiscal-document authority never leave OpenLinker (ADR-056). Every other ADR-052 authority is assignable to a connection; A6 (who issues refunds) and A7 (who issues invoices and receipts) are not, and no capability name exists for either. #2351 is ADR-056’s first implementer: resolveAuthorities answers A6 as a hard-coded OpenLinker — always before any claimant is consulted, so no Connection.config value can be honoured as a grant and two claimants cannot manufacture an ambiguity, and it answers A7 as a link to the existing ADR-041 sales-document configuration rather than mirroring it. A refundTrigger config key is still read so a claim is at least legible in the config — but nothing REPORTS it: the A6 row resolves fixed-by-design with an empty inactiveClaimantConnectionIds, so an operator who set it is never told it was ignored. Surfacing an ignored A6 claim is unbuilt, not merely undone. The intended shape stays as ADR-056 states it — an OMS requests a refund and OL executes or refuses with a persisted reason. ReturnsAuthority (A5) is the counter-case, and the reason the closed CoreCapabilityValues set grew to ten in the same change: its holder is named by an operator enabling it on a connection, and the connection DTOs @IsIn-validate enabledCapabilities against that array, so a name kept out of it is a name an operator cannot write. But no adapter manifest advertises ReturnsAuthority yet, and both capability-checkbox surfaces gate on the adapter’s advertised list intersected with the core set — so A5 cannot be assigned through the UI at all today, only by a hand-rolled PATCH /connections/:id. Nothing is silently dropped (the value round-trips), so this is a reachability gap rather than data loss; it matters because an ambiguous A5 row on the who-decides page cannot have been caused by anything an operator did in the product.
This same getCapabilityAdapter seam — with its per-connection gating and encrypted credential isolation sitting below it — is the mount point for exposing OpenLinker as an MCP server (agents drive OL), where MCP tools become a new Interface-layer adapter over the existing application services and tools/list is dynamic and capability-declared (each tool declares a required capability/sub-capability and is registered iff an in-scope connection supports it — a base port backs several tools, a decomposed port one per sub-capability; connectionId as an argument, via the is{Capability} guards). See ADR-033 for the decision, security model, and phased plan, and ADR-034 for the auth layer (OL as an OAuth 2.1 Resource Server validating user-issued Personal Access Tokens; an OAuth Authorization Server is a deferred optional upgrade).
MCP auth is shipped (Phase 0, #1486). OL is an OAuth 2.1 Resource Server that validates its own
user-issued Personal Access Tokens — there is no Authorization Server. The seam is the MCP SDK’s own
OAuthTokenVerifier interface, implemented by OlMcpTokenVerifier (apps/api/src/mcp/auth/); the SDK’s
requireBearerAuth middleware then owns the 401/403 split, the WWW-Authenticate challenge, and scope
enforcement. Tokens are opaque (olmcp_-prefixed, SHA-256 at rest) with mandatory expiry, mcp:read/mcp:write
scopes, and an RFC 8707 resource binding; they live in mcp_tokens in the users bounded context, because a
service outside that context may not inject a *RepositoryPort — apps/api/src/mcp/ crosses the boundary
through IMcpTokenService (MCP_TOKEN_SERVICE_TOKEN) instead. A token resolves to its owning OL user and
inherits that user’s RBAC role, so it can never exceed its owner. The principal reaches tool handlers as
ctx.authInfo on the request-scoped McpRequestContext (the context the SDK hands a tool callback at
dispatch time does NOT carry it, so the registry threads the request-scoped value into each handler — #1487);
note that AuthInfo carries the raw bearer token, so it must never be logged or serialized
wholesale — consumers log a redacted projection from AuthInfo.extra via redactPrincipal. Adding an OAuth AS
later swaps the verifier implementation and nothing else.
Read-only domain tools are shipped (Phase 1, #1487). Six tools live at apps/api/src/mcp/tools/: two
always-registered discovery entry points (whoami, list_connections) plus four capability-gated domain reads
(search_catalog / get_product on ProductMaster, get_availability on InventoryMaster, get_order on
OrderSource). Two design points shape everything downstream:
- Capability-gated but OL-store-backed.
McpToolRegistryServiceregisters a tool ifflistCapabilityAdapters({ capability, lazy: true })finds a supporting-and-enabled connection, but the tools read OL’s own store throughIProductsService/IInventoryQueryService/IOrderRecordService— never the capability port’s adapter. Going through the port would make each tool call a live marketplace request (spending the operator’s API quota on an agent’s behalf), return external-id-keyed data that can’t join the internal-id-keyed product reads, and — decisively — re-fetch buyer PII regardless ofOL_STORE_PII. The consequence is that the gate and the data are independent facts: a passing gate does not imply data exists. Each tool’s description says so, because an agent cannot otherwise read an empty result. See ADR-033 § Phase 1 amendments. - One choke point. Rate limiting (per-token ZSET rolling window + in-flight cap, fail-open on Redis outage),
audit logging, and error mapping are applied by a single wrapper inside
McpToolRegistryService— never by a tool. A*.tool.tsfile contains only its read and its projection, so a concern like releasing an in-flight slot on the throw path cannot be forgotten per tool. Every result is an explicit-allowlist projection: tool output reaches an external LLM provider, solist_connectionsnever emitscredentialsRef/configandget_ordernever emits buyer name/email/address even when the snapshot stores them.
Mapping-assistant tools + per-tool authorization are shipped (Phase 2, #1488). Five tools at
apps/api/src/mcp/tools/ — four reads (list_category_mappings, list_attribute_mappings,
resolve_category, project_attributes) and OL’s first MCP write (upsert_category_mapping) — plus the
configure-mappings Skill encoding the discover → resolve → confirm → write → verify loop. Three design points:
- Per-tool scope + role, enforced in one place.
McpToolDefinitioncarriesrequiredScope+requiresAdmin(both required — a write tool that forgotrequiresAdminwould otherwise default to unprivileged).McpToolRegistryServicechecks at registration and at call time before the rate-limiter acquires, so a refusal costs no budget and audits asoutcome: 'forbidden'. Registration filtering is a listing convenience; the call-time check is the guard. Note that because a handler closes over the request-scoped ctx and registration runs against that same principal, the two cannot diverge today — the call-time check is defence-in-depth for a sessionful transport (#1932 option 2). - Ungated, deliberately. These tools declare
requiredCapability: null: mapping configuration is OL-owned data, not adapter-served, so a marketplace-capability gate would imply something false about the data. The exception to Phase 1’s OL-store-backed rule isresolve_category/project_attributes, which do reach the live destination (barcode catalogue lookup, category schema). Admitted as bounded — one call per invocation, not the N-call tree walk that keepsbrowse_categoriesdeferred to #1937 — and stated in the tool descriptions so an agent does not loop on them. - The destination’s kind must be DISCOVERED, not assumed.
OfferBuilderServiceandProductPublishBuilderServiceeach know their destination kind statically; an MCP tool does not, because the agent supplies an arbitraryconnectionIdfromlist_connections.resolveDestinationContext(apps/api/src/mcp/tools/read/destination-context.ts) probesOfferManagerthenProductPublisherto resolve two facts the core services cannot derive themselves: which capability exposes the live category schema (a shop serves it underProductPublisherand does not supportOfferManager— projecting a shop under the default would throw), and whether the destination borrows a taxonomy (#1045), without whichresolve_categoryreportsmanualfor an Erli connection whose operator already has a working Allegro-authored mapping. - Two traps the write path had to avoid.
upsert_category_mappingrequiressourceConnectionIdeven thoughCategoryMappingInputmarks it optional (#1036 record-only): the repository upsert matches that column withIsNull()semantics whilefindBySourceCategoryis oldest-wins, so an omitted value inserts a duplicate row and the write reports success while changing nothing. And the audit wrapper now readsdestinationConnectionIdas well asconnectionId, or every mapping-tool audit line — including the write — would have recorded an undefined connection.
The semantic half of Phase 2 (taxonomy browse/search, the LLM-shaped matching the issue was framed around)
is not shipped: it needs a neutral destination-taxonomy read model, tracked as #1937 Wave 4.
resolve_category is named for what it is — a deterministic placement chain (provision → barcode → configured
mapping → manual), not a suggester. Its mcp:read declaration holds only while
CategoryResolutionService.tryProvision() remains a stub; a spec asserts the tool never resolves via
provision, so #1041 wiring that step fails the build instead of silently granting read tokens a destination
write. Order-mapping options tools (status/carrier/payment) are also deferred — that vocabulary is only
partially neutralized (ADR-023 / #1036).
notifications/tools/list_changed is deliberately not implemented: stateless per-request serving leaves no
session to push over. Note the accepted cost — the server is always fresh, but a client’s cached tool list
can go stale, so an agent already connected when a connection is enabled won’t see the new tool until it
reconnects. See ADR-033 § Phase 1 amendments. The
operator-facing half of that cost is documented in the product (#1932): the MCP tokens settings page states
that tool availability follows enabled connections and that a connection change needs a client reconnect.
Closing the gap for real means adopting sessions, which reverses part of ADR-033 — so it starts with an
amendment, not with UI.
Identifier Mapping Service
Section titled “Identifier Mapping Service”Overview
Section titled “Overview”The IdentifierMappingService is a core infrastructure service responsible for managing the mapping between external platform identifiers (e.g., PrestaShop product ID, Allegro order ID) and internal OpenLinker identifiers. It ensures that all entities in the system have unique internal identifiers from a single unified seed, regardless of their origin platform.
Key Responsibilities
Section titled “Key Responsibilities”- Generate Internal Identifiers: Creates new unique internal IDs for entities when they are first encountered from external platforms
- Map External to Internal: Provides mapping from external platform IDs to internal OpenLinker IDs
- Context-Aware Mapping: Handles mapping based on entity type (Product, Order, Offer, etc.), platform, and context
- Maintain Mapping Registry: Stores and retrieves mappings between external and internal identifiers
Connection Entity
Section titled “Connection Entity”The system supports multiple integrations of the same platform (e.g., two PrestaShop stores). Each integration is represented by a Connection entity:
interface Connection { id: string; // Unique connection ID platformType: string; // 'prestashop', 'allegro', etc. name: string; // Human-readable name status: 'active' | 'disabled' | 'error' | 'needs_reauth'; config: Record<string, any>; // Connection-specific configuration credentialsRef: string; // Reference to credentials storage createdAt: Date; updatedAt: Date;}Why connections?
- Support multiple instances of the same platform (e.g., multiple PrestaShop stores)
- Each connection has its own configuration and credentials
- Mappings are connection-scoped, not platform-scoped
Interface
Section titled “Interface”interface IdentifierMappingService { /** * Get or create internal identifier for an external entity * If mapping exists, returns existing internal ID * If not, generates new internal ID and creates mapping */ getOrCreateInternalId( entityType: CoreEntityType | string, externalId: string, connectionId: string, // ✅ Connection ID (not platform ID) context?: MappingContext ): Promise<string>;
/** * Get internal identifier for an external entity * Returns null if mapping doesn't exist */ getInternalId( entityType: CoreEntityType | string, externalId: string, connectionId: string // ✅ Connection ID ): Promise<string | null>;
/** * Get external identifier(s) for an internal ID * Returns all connection-specific external IDs mapped to this internal ID */ getExternalIds( entityType: CoreEntityType | string, internalId: string ): Promise<ExternalIdMapping[]>;
/** * Create explicit mapping between external and internal identifiers * Used for manual mapping or when internal ID already exists */ createMapping( entityType: CoreEntityType | string, externalId: string, connectionId: string, // ✅ Connection ID internalId: string ): Promise<void>;
/** * Batch get or create internal identifiers * Optimized for processing multiple entities at once */ batchGetOrCreateInternalIds( requests: IdentifierMappingRequest[] ): Promise<Map<string, string>>; // externalId -> internalId}
interface MappingContext { parentEntityType?: string; parentInternalId?: string; metadata?: Record<string, any>;}
interface IdentifierMappingRequest { entityType: CoreEntityType | string; externalId: string; connectionId: string; // ✅ Connection ID context?: MappingContext;}
interface ExternalIdMapping { externalId: string; platformType: string; // Denormalized from Connection connectionId: string; // ✅ Connection ID entityType: string;}Internal Identifier Format
Section titled “Internal Identifier Format”Internal identifiers are generated from a single unified seed across all entity types:
- Format:
ol_{prefix}_{uuid}whereprefixdefaults toentityType.toLowerCase() - Examples:
ol_product_fce2df4d853f4499b955a6bb1a212bd1,ol_variant_e4b98e91340a44edb4892905db8810b1,ol_order_xyz789,ol_offer_def456,ol_shipment_a3f24b09c4d1486789abcdef01234567(#763) - Uniqueness: Guaranteed across all entities in the system
- Database Storage: Internal IDs are stored as
TEXTtype in PostgreSQL (not UUID) - Prefix overrides: A small
ENTITY_TYPE_ID_PREFIXmap inidentifier-mapping.types.tsoverrides the default for entity types where the documented prefix diverges from the lowercased class name. Today:ProductVariant → variant(so IDs areol_variant_*, notol_productvariant_*). - Canonical Entities: Product, ProductVariant, InventoryItem use internal IDs as primary keys
Usage by Adapters
Section titled “Usage by Adapters”Adapters are responsible for:
- Fetching data from external platforms
- Transforming data to OpenLinker unified schema
- Replacing external identifiers with internal identifiers using
IdentifierMappingService
Example: PrestaShop Product Adapter
@Injectable()export class PrestashopProductAdapter implements ProductMasterPort { constructor( private readonly identifierMapping: IdentifierMappingService, private readonly httpService: HttpService, private readonly connectionId: string, // ✅ Connection ID for this PrestaShop instance ) {}
async getProduct(productId: string): Promise<Product> { // 1. Fetch product from PrestaShop API const prestashopProduct = await this.httpService.get( `/products/${productId}` );
// 2. Transform to OpenLinker schema const product: Product = { // ... map PrestaShop fields to OpenLinker schema name: prestashopProduct.name, sku: prestashopProduct.reference, // ... };
// 3. Replace external ID with internal ID (using connectionId) const internalId = await this.identifierMapping.getOrCreateInternalId( 'Product', productId, // PrestaShop product ID this.connectionId // ✅ Connection ID (not platform type) );
// 4. Use internal ID in the returned product return { ...product, id: internalId, // Internal OpenLinker ID externalIds: { prestashop: productId, // Keep external ID for reference }, }; }}Example: Allegro Order Source Adapter
@Injectable()export class AllegroOrderSourceAdapter implements OrderSourcePort { constructor( private readonly connectionId: string, private readonly httpClient: IAllegroHttpClient, private readonly identifierMapping: IdentifierMappingPort, ) {}
async listOrderFeed(input: OrderFeedInput): Promise<OrderFeedOutput> { // 1. Fetch incremental order events from Allegro const response = await this.httpClient.get<AllegroOrderEventsResponse>('/order/events', { queryParams: { from: input.fromCursor ?? undefined, limit: input.limit }, });
// 2. Dedupe by checkoutFormId, map to the neutral OrderFeedItem shape const items = this.buildFeedItems(response.data.events);
// 3. Next cursor is Allegro-assigned (monotonic per seller) const nextCursor = response.data.lastEventId ?? items.at(-1)?.eventKey ?? input.fromCursor ?? null;
return { items, nextCursor }; }
async getOrder(input: { externalOrderId: string }): Promise<IncomingOrder> { // 1. Hydrate checkout-form from Allegro const checkoutForm = await this.httpClient.get<AllegroCheckoutForm>( `/order/checkout-forms/${input.externalOrderId}`, );
// 2. Map to the neutral IncomingOrder DTO — identifier mapping happens // downstream in OrderIngestionService, not in the adapter. return this.toIncomingOrder(checkoutForm.data); }}Storage
Section titled “Storage”Mappings are stored in PostgreSQL:
// Connection entity@Entity('connections')class Connection { @PrimaryGeneratedColumn('uuid') id: string;
@Column() platformType: string; // 'prestashop', 'allegro', etc.
@Column() name: string;
@Column() status: string; // 'active', 'disabled', 'error'
@Column({ type: 'jsonb', nullable: true }) config: Record<string, any>;
@Column({ nullable: true }) credentialsRef: string | null;
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;}
// Identifier mapping entity@Entity('identifier_mappings')class IdentifierMapping { @PrimaryGeneratedColumn('uuid') id: string;
@Column() entityType: string; // 'Product', 'Order', 'Offer', etc.
@Column() internalId: string; // OpenLinker internal ID
@Column() externalId: string; // External platform ID
@Column() platformType: string; // ✅ Denormalized from Connection (for query performance)
@Column() connectionId: string; // ✅ References connections.id
@Column({ type: 'jsonb', nullable: true }) context: MappingContext;
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;
// ✅ Unique constraint: entityType + platformType + connectionId + externalId @Index(['entityType', 'platformType', 'connectionId', 'externalId'], { unique: true }) @Index(['entityType', 'internalId']) // Reverse lookup}Why denormalize platformType?
- Query performance: Avoids JOINs for common queries
- Index efficiency: Unique constraint includes
platformTypefor faster lookups - Data integrity:
platformTypeis immutable on Connection, safe to denormalize
Benefits
Section titled “Benefits”- Unified Identity: All entities have consistent internal identifiers regardless of source
- Platform Agnostic: Core domain logic works with internal IDs only
- Traceability: Can always find external IDs from internal IDs and vice versa
- Adapter Responsibility: Adapters handle ID translation, keeping core domain clean
- Single Source of Truth: One service manages all identifier mappings
Customer Identity Resolution
Section titled “Customer Identity Resolution”OpenLinker provides a customer identity resolution service that enables multi-origin customer identity management. This allows the same customer to be recognized across different platforms (e.g., Allegro, PrestaShop direct orders) based on email address.
Identity Resolution Modes
Section titled “Identity Resolution Modes”External-Only Mode (OL_CUSTOMER_IDENTITY_MODE=external_only):
- Only uses external buyer ID mapping (source connection scoped)
- No email fallback
- Each external buyer ID maps to a unique internal customer ID
- Use Case: When email sharing is common (families, businesses) and you want to avoid incorrect customer merging
Email Fallback Mode (OL_CUSTOMER_IDENTITY_MODE=email_fallback, default):
- Primary: External buyer ID mapping
- Fallback: Email hash lookup to link customers across origins
- Same email → same internal customer ID (across different platforms)
- Use Case: Better user experience, same customer recognized across platforms
- Risk: Shared emails (families, businesses) may incorrectly merge customers
- Mitigation: Collision policy creates new customer if >1 match (no merge)
Customer Provisioning Model (Model A)
Section titled “Customer Provisioning Model (Model A)”Customers are destination-owned: the destination platform (e.g., PrestaShop) is the source of truth for customer data. OpenLinker adapters are responsible for creating/updating customers in the external system.
Example: When an Allegro order arrives for a customer that doesn’t exist in PrestaShop:
- PrestaShop adapter provisions a guest customer (
is_guest=1) - Customer is created with valid password (5-72 chars, PrestaShop hashes internally)
- Customer ID is stored in identifier mappings for future reuse
Customer Projection Model (Model C)
Section titled “Customer Projection Model (Model C)”OpenLinker stores lightweight, non-authoritative projections of customer data for:
- Debugging: Track customer history across orders
- Retry Support: Enable order retry without re-fetching from source
- Future Routing: Support for future customer routing features
Projection Storage:
customer_projections: Customer email hash, optional PII (name, email)customer_address_projections: Address hash, optional PII (address fields)destination_address_mappings: Maps internal customer + address hash → destination address ID
PII Configuration (OL_STORE_PII):
true(default): Store raw PII (email, names, addresses)false: Store only hashes (emailHash, addressHash) - no raw PII- Note:
emailHashis always persisted regardless of PII setting
Email Normalization
Section titled “Email Normalization”OpenLinker normalizes emails before hashing to handle platform-specific email formats:
Allegro Masked Emails:
- Format:
fixedPart+transactionId@allegromail.* - Normalization: Strip
+...suffix before hashing - Example:
8awgqyk6a5+cub31c122@allegromail.pl→8awgqyk6a5@allegromail.pl - Why: Transaction ID changes per order, but fixed part is stable per buyer
Address Reuse
Section titled “Address Reuse”Addresses are reused across orders when identical (determined by hash):
- Hash Components:
address1,address2,city,postcode,countryIso2 - Reuse Priority:
- Primary: Query
destination_address_mappingstable (fast, deterministic) - Fallback: Query PrestaShop addresses and match by hash (recovery scenario)
- Primary: Query
- Address Alias: Deterministic alias format:
OL-{type}-{hash-prefix}(e.g.,OL-shipping-a1b2c3)
Collision Handling
Section titled “Collision Handling”When emailHash matches multiple customers (collision):
- Policy: Create new internal customer (no merge)
- Logging: Warning logged with emailHash and match count
- Result:
collisionDetected=truein resolution result - Rationale: Prevents incorrect customer merging (shared emails in families/businesses)
Hexagonal Architecture Structure
Section titled “Hexagonal Architecture Structure”See ADR-011 for the domain entity behavior policy (anemic-by-default with pure read-only derivations).
Each domain module follows a standardized hexagonal structure:
libs/core/src/{domain}/├── domain/ # Domain Layer (Pure Business Logic)│ ├── entities/ # Domain Entities / Aggregates│ │ ├── product.entity.ts│ │ └── product-variant.entity.ts│ ├── value-objects/ # Value Objects│ │ ├── money.vo.ts│ │ └── sku.vo.ts│ ├── domain-services/ # Domain Services│ │ └── product-mapping.service.ts│ ├── domain-events/ # Domain Events│ │ └── product-created.event.ts│ └── ports/ # Ports (Interfaces)│ ├── product-master.port.ts│ ├── inventory-master.port.ts│ ├── order-processor-manager.port.ts│ ├── product-repository.port.ts # Repository ports (persistence contracts)│ └── connection.port.ts│├── application/ # Application Layer (Use Cases)│ ├── use-cases/ # Use Case Implementations│ │ ├── sync-product.use-case.ts│ │ └── map-product.use-case.ts│ ├── services/ # Application Services│ │ └── product-sync.service.ts│ └── dto/ # Application DTOs│ ├── product-sync.dto.ts│ └── product-mapping.dto.ts│├── infrastructure/ # Infrastructure Layer│ ├── persistence/ # Database│ │ ├── entities/ # TypeORM Entities│ │ │ └── product.orm-entity.ts│ │ └── repositories/ # Repository Implementations│ │ └── product.repository.ts│ ├── adapters/ # External Adapters│ │ ├── prestashop-product-master.adapter.ts│ │ ├── prestashop-inventory-master.adapter.ts│ │ └── prestashop-order-processor.adapter.ts│ └── mappers/ # Data Mappers│ └── product.mapper.ts│└── interfaces/ # Interface Layer ├── http/ # HTTP Controllers │ ├── product.controller.ts │ └── product.controller.spec.ts ├── events/ # Event Handlers │ └── product-event.handler.ts └── dto/ # Request/Response DTOs ├── create-product.dto.ts └── product-response.dto.tsLayer Dependencies
Section titled “Layer Dependencies”interfaces → application → domain ↓ ↓infrastructure → domainRules:
- Domain has NO dependencies on NestJS, TypeORM, or any framework code
- Domain depends only on ports (interfaces)
- Application depends on domain and ports (never on infrastructure)
- Infrastructure implements ports and depends on domain
- Interfaces depend on application and infrastructure
Repository Ports Pattern
Section titled “Repository Ports Pattern”Application services must never depend on concrete infrastructure repositories. Instead, they depend on repository ports (interfaces) defined in the domain layer.
Why:
- Maintains proper dependency direction (application → domain, not application → infrastructure)
- Enables easy testing (mock the port interface)
- Allows swapping implementations (e.g., in-memory repository for tests)
- Follows Dependency Inversion Principle
Pattern:
-
Define repository port in domain layer:
domain/ports/product-repository.port.ts export interface ProductRepositoryPort {findById(id: string): Promise<Product | null>;save(product: Product): Promise<Product>;// ... only methods needed by application services} -
Implement port in infrastructure layer:
infrastructure/persistence/repositories/product.repository.ts @Injectable()export class ProductRepository implements ProductRepositoryPort {// Implementation using TypeORM} -
Inject port (not concrete class) in application service:
application/services/product.service.ts @Injectable()export class ProductService {constructor(@Inject(PRODUCT_REPOSITORY_TOKEN)private readonly repository: ProductRepositoryPort, // ✅ Port interface) {}} -
Bind in module with token:
product.module.ts export const PRODUCT_REPOSITORY_TOKEN = Symbol('ProductRepositoryPort');providers: [ProductRepository,{provide: PRODUCT_REPOSITORY_TOKEN,useExisting: ProductRepository,},]
ORM ↔ Domain Mapping:
- Mapping lives in infrastructure persistence layer (repository or dedicated mapper)
- Application services work only with domain entities, never ORM entities
- Mapping methods (
toDomain,toOrm) are private in repository (or extracted to mapper if reused)
✅ Good:
// Repository handles mapping internally@Injectable()export class ProductRepository implements ProductRepositoryPort { async findById(id: string): Promise<Product | null> { const entity = await this.ormRepository.findOne({ where: { id } }); return entity ? this.toDomain(entity) : null; // Private mapping method }
private toDomain(entity: ProductOrmEntity): Product { ... } private toOrm(product: Product): ProductOrmEntity { ... }}❌ Bad:
// Service imports infrastructure repository directlyimport { ProductRepository } from '../infrastructure/persistence/repositories/product.repository';
// Service works with ORM entitiesconst ormEntity = await this.repository.findOrmEntity(id); // ❌Repository Error Handling:
- Repositories must throw domain errors, not infrastructure errors
- Catch infrastructure-specific errors (TypeORM, database) and convert to domain exceptions
- Application services handle domain errors, not infrastructure errors
✅ Good:
// Repository throws domain error@Injectable()export class ProductRepository implements ProductRepositoryPort { async insertMapping(mapping: IdentifierMapping): Promise<IdentifierMapping> { try { const saved = await this.ormRepository.save(this.toOrm(mapping)); return this.toDomain(saved); } catch (error) { // Convert infrastructure error to domain error if (error instanceof QueryFailedError && error.message.includes('duplicate key')) { throw new DuplicateIdentifierMappingError(...); // ✅ Domain error } throw error; } }}
// Service handles domain error@Injectable()export class ProductService { async createMapping(...) { try { await this.repository.insertMapping(mapping); } catch (error) { if (error instanceof DuplicateIdentifierMappingError) { // Handle domain error - no infrastructure awareness } } }}❌ Bad:
// Repository port exposes infrastructure-specific error checkingexport interface ProductRepositoryPort { insertMapping(...): Promise<...>; isUniqueViolationError(error: unknown): boolean; // ❌ Infrastructure-specific}
// Service depends on infrastructure error typescatch (error) { if (error instanceof QueryFailedError) { // ❌ Infrastructure awareness // ... }}Cross-context dependencies in core
Section titled “Cross-context dependencies in core”Each bounded context in libs/core/src/<ctx> is an independently testable hexagonal cell, but contexts legitimately depend on each other — orders needs customers and identifier-mapping for identity resolution, content needs ai for suggestions, listings needs products to walk variant catalogs. The architecture supports those dependencies via a single, narrow contract surface between contexts. This section names that contract.
The rule
Section titled “The rule”A file in libs/core/src/<ctx-A>/** may import from @openlinker/core/<ctx-B> (the top-level barrel of any sibling context) only the following kinds of symbols:
| Allowed | Pattern | Example |
|---|---|---|
| Service interfaces | I*Service | IIntegrationsService, IIdentifierMappingService |
| DI tokens (Symbol) | *_TOKEN | INTEGRATIONS_SERVICE_TOKEN, EVENT_PUBLISHER_TOKEN |
| Capability ports | *Port (single Port suffix) | OfferManagerPort, OrderSourcePort, EventPublisherPort |
| Capability type-guards | is* | isOfferCreator, isCategoryBarcodeMatcher |
| Domain entities, value objects, type aliases | published in the barrel | Connection, Product, Order, MarketplaceCursor |
| Domain exceptions | *Exception, *Error | ConnectionNotFoundException, DuplicateIdentifierMappingError |
Other as const value constants | UPPER_SNAKE_CASE | CORE_ENTITY_TYPE, OFFER_CREATION_STATUS |
| NestJS module classes | *Module — for imports: [...] only, never injected into services | CustomersModule in orders.module.ts |
The cross-context contract is explicitly forbidden for these symbol shapes:
| Forbidden | Pattern | Why |
|---|---|---|
| Repository ports | *RepositoryPort | Intra-context contract — exposes persistence concerns the source context controls. Cross-context callers go through I*Service. |
| ORM entities | *OrmEntity | TypeORM-decorated infrastructure detail (and ESLint-guarded under @openlinker/core/<ctx>/orm-entities separately). |
| Adapter classes | *Adapter | Concrete infrastructure; sibling contexts see behaviour through I*Service or capability ports, never the adapter directly. |
| Application DTOs | *Dto | Owned by the source context’s interface layer. |
| Default imports | import X from '@openlinker/core/<ctx>' | Barrels have no default export. |
| Namespace imports | import * as X from '@openlinker/core/<ctx>' | Reserved for barrel-purity tests; cross-context callers use named imports so the surface they touch is explicit. |
The rule applies only to imports from the bare top-level barrel @openlinker/core/<ctx>. The four documented sub-barrel families (/services, /orm-entities, /testing, /types) are governed separately - the first three by their own ESLint rules, and the /types cycle-breakers by scripts/check-types-sub-barrels.mjs, which bounds what those files may re-export at the source. See docs/engineering-standards.md § Import Aliases.
Why each rule exists
Section titled “Why each rule exists”- Service interfaces are the seam. A context’s
I*Serviceshape is the only thing sibling contexts can rely on staying stable. Whenproductsreorganises its repository layout, the consumers inorders/listings/inventorydon’t break — they kept askingIProductsServicefor what they needed, andproductsis free to change how it answers internally. - Capability ports are part of the published contract.
OfferManagerPort,ProductMasterPort,OrderSourcePort— these are the abstractions adapters implement. They cross context boundaries because they’re how the marketplace integrations express themselves to the rest of core. Repository ports, by contrast, are persistence concerns that no sibling has business reaching into. - Domain entities cross by value. When
ordersimportsProductfrom@openlinker/core/products, it binds to the published shape —productscan evolve internals freely, and any breaking shape change surfaces at type-check time. Value imports of entities are intentionally allowed (services construct, return, and pattern-match on them); the contract surface is what’s published from the barrel, not the import kind. - NestJS module classes cross only at the module-graph layer.
orders.module.tsimportsCustomersModuleto compose providers. A service constructor must never type-hintCustomersModuledirectly — that’s the wrong layer.
Current dependency map
Section titled “Current dependency map”Audited 2026-06-26 from libs/core/src/**:
graph LR orders --> currency orders --> customers orders --> identifier-mapping orders --> integrations orders --> invoicing orders --> mappings orders --> products orders --> sync orders --> inventory shipping --> inventory invoicing --> orders invoicing --> identifier-mapping invoicing --> integrations invoicing --> sync customers --> identifier-mapping customers --> integrations customers --> orders content --> ai content --> integrations content --> listings content --> products listings --> identifier-mapping listings --> integrations listings --> inventory listings --> mappings listings --> orders listings --> products listings --> shipping listings --> sync inventory --> identifier-mapping inventory --> integrations inventory --> listings inventory --> products inventory --> sync products --> identifier-mapping products --> integrations products --> listings sync --> listings sync --> orders ai --> integrations integrations --> identifier-mapping shipping --> integrations shipping --> mappings shipping --> orders shipping --> identifier-mapping shipping --> sync shipping --> fulfillment mailer --> integrations invoicing --> sales-documents orders --> sales-documents orders --> fulfillment automation --> order-lifecycle orders --> fiscalization analytics-trust --> integrations analytics-trust --> sync analytics-trust --> orders analytics --> orders fiscalization --> invoicing fiscalization --> sales-documents fiscalization --> sync invoicing --> fiscalization orders --> order-lifecycle returns --> orders returns --> identifier-mapping returns --> integrations returns --> sync orders --> returns orders --> fulfillment-authority returns --> fulfillment-authority returns --> inventory fulfillment --> fulfillment-authorityidentifier-mapping, integrations, and events form the most-depended-upon “infrastructure spine” (each used by 5+ siblings). The sync --> events edge was removed in #2163: SyncJobBulkRetryService was the context’s only consumer of EVENT_PUBLISHER_TOKEN, and deleting the write-only events.sync.jobs publish left SyncModule importing EventsModule to satisfy an injection that no longer existed. users, webhooks, and mappings have minimal outbound coupling. operational-settings (#2651) has none either, and no inbound core edge at all - both host apps consume it directly (the api for the admin surface, the worker for its sweep handlers and scheduler). currency has none: it is a leaf with a single inbound edge from orders (ADR-040 § Decision 7), which is why ICurrencyRateService takes an already-resolved rate date and source rather than reading placedAt back out of an order.
sales-documents (#2100) is the opposite extreme and deliberately so: a sink with zero outbound edges to sibling CORE contexts. As of #2170 it is no longer framework-free (it carries its own SalesDocumentsModule, an application service, and repositories backing the rule engine), but nothing in it injects @openlinker/core/<sibling> — which is what lets both invoicing and orders value-import it from a domain entity, a repository port and an application service without any risk of a CJS module-load cycle. The concern now has an ordinary sales-documents.tokens.ts — the <ctx>.tokens.ts exception (engineering-standards.md § Symbol DI Token Re-export Convention) applied only while the concern had no DI bindings to discover, and ended with #2170. Framework-freedom and sibling-edge-freedom are different properties, and only the second is enforced; #2170 is the proof they are separable.
There are now four such leaves. ADR-053 adopted the posture deliberately for the OMS vocabulary concerns, so fulfillment-authority (#2304, § 20), order-lifecycle (#2305, § 21) and fulfillment (#2391, § 26) join sales-documents. All four are pinned by libs/core/src/__tests__/barrel-purity.spec.ts, generalised in #2308 from a single hardcoded root into a ZERO_SIBLING_EDGE_LEAVES table — one line per leaf, carrying that leaf’s own allow-set of authorized type-only specifiers. The spec fails on any non-relative @openlinker/core/<ctx> import added under a registered directory; @nestjs/* / typeorm / node:* imports are unrestricted. The allow-sets are per leaf rather than shared on purpose: a shared constant would silently authorise one leaf’s future exception for every other. sales-documents and order-lifecycle each carry exactly one entry (@openlinker/core/orders/types — #2155’s Order and ADR-059’s OrderStatus respectively, both type-only and therefore erased at build time); fulfillment’s is one entry too (@openlinker/core/fulfillment-authority, #2391 — the first main-barrel authorization in that table, admissible only because the import is type-only AND the target is itself a registered leaf exporting no NestJS module); fulfillment-authority’s is empty, which is a positive assertion that it reaches no sibling at all.
The leaf property is INBOUND-edge-tolerant, and the map reflects that. What “leaf” asserts is that the context imports no sibling — edges point into it, never out of it. So a leaf gaining consumers is the expected evolution, not an erosion of the guarantee; a leaf gaining an outbound edge is the failure the spec exists to catch. order-lifecycle has already made that transition: as of #2309 it carries orders --> order-lifecycle in the map above — a real runtime (value) edge, since OrderRecordRepository value-imports OrderLifecyclePhaseValues to build its SQL twin, alongside three type-only edges from the orders context and its HTTP DTOs. It remains a leaf: barrel-purity.spec.ts confirms zero outbound edges, which is what keeps that value import cycle-safe. fulfillment-authority gained its first inbound edges in #2352: orders --> fulfillment-authority and returns --> fulfillment-authority, both runtime (value) edges, since OrderRecordRepository value-imports AuthorityAttentionCountedReasonValues to build its SQL twin and both repositories value-import readAuthorityAttentionEntries to coerce a persisted column at the mapping boundary. It remains a leaf, and its allow-set is still empty — the edges point only inward, which is exactly the direction the property tolerates and what keeps those value imports cycle-safe. fulfillment gained ITS first inbound edge in #2401: orders --> fulfillment, a runtime (value) edge, since FulfillmentDispatchRelayService injects FULFILLMENT_RELAY_GATE_SERVICE_TOKEN to take the work’s at-most-once dispatch-relay claim. Same reading — it stays a leaf with zero outbound edges, and the direction is forced rather than merely tolerated here: firing the relay from inside fulfillment would require importing @openlinker/core/orders, which two guards independently forbid, so the consumer had to live in orders and the edge had to point this way.
returns (#2327, § 22) is deliberately not one of them, and each slice has moved it further from the property rather than closer: it now carries seven real outbound edges. returns --> identifier-mapping (#2328) is a NestJS module edge — ReturnsService resolves a source-native order id to an OL internal one through IIdentifierMappingService. returns --> integrations and returns --> sync (#2330) are likewise module edges: the two ingestion services resolve the connection’s OrderSource adapter, and take the cursor seam, the job queue and the lock. All three are acyclic — none of those modules imports this one. returns --> orders began type-only (the RefundReason vocabulary off the @openlinker/core/orders/types cycle-breaker sub-barrel), became a real value edge with #2330’s isReturnSourceReader guard, and as of #2333 is a real NestJS module edge: ReturnsModule imports OrderChangesModule — deliberately that leaf module rather than OrdersModule — and ReturnDeclineService injects ORDER_CHANGE_SERVICE_TOKEN. returns --> invoicing is the fifth (#2374) — ReturnCorrectionProposalService reads the issued-line snapshot through IInvoiceService, and it too is acyclic (InvoicingModule imports only IntegrationsModule / IdentifierMappingModule / SyncModule / SalesDocumentsModule) and strictly READ-ONLY: nothing on that path issues a document. returns --> fulfillment-authority is the sixth (#2352) — a real value edge: ReturnRepository value-imports readAuthorityAttentionEntries to coerce the omsAttention column at the mapping boundary, and ReturnRecord takes AuthorityAttentionEntry as its last constructor parameter. It is the inbound half of the edge the paragraph above records from fulfillment-authority’s own side, and it is acyclic for that context’s defining reason: the leaf imports no sibling, so nothing points back. returns --> inventory is the seventh (#3486) — a NestJS module edge: ReturnCustodyService injects INVENTORY_QUERY_SERVICE_TOKEN to ask IInventoryQueryService.resolveStockOwner which product master owns a returned line’s stock, never the inventory repository port. Acyclic: InventoryModule imports Products / Integrations / IdentifierMapping / Sync / Events, none of which reaches this one. Registering the context in ZERO_SIBLING_EDGE_LEAVES would be a false claim rather than a tidy one.
The direction still runs one way, and that is the invariant to hold. The map’s orders --> returns edge is type-only — return-decliner.capability.ts imports the ReturnDeclineCommand / ReturnDeclineResult shapes with import type, which erases at build time — so orders creates no module-graph edge back and OrdersModule must never appear in ReturnsModule’s imports. Adding it “for symmetry” would manufacture exactly the CJS module-load cycle the rule exists to prevent. returns does share the leaves’ root-barrel posture, for the same cycle-safety reason. libs/core/src/returns/returns.module.ts’s own docblock is the fuller per-slice account of these edges.
None of the three is re-exported from the aggregating root barrel libs/core/src/index.ts, and that is also pinned by the same spec (#2308). Requiring the root barrel evaluates every listed context in one module graph, so a leaf whose entire value is cycle-safe value-importability gains nothing there and takes on the exact hazard it exists to avoid; each stays reachable at its own declared @openlinker/core/<ctx> subpath. The root barrel is not an inventory of contexts either way — several are legitimately absent.
sales-documents (#2100) is the opposite extreme and deliberately so: a sink with zero outbound edges to sibling CORE contexts. As of #2170 it is no longer framework-free (it carries its own SalesDocumentsModule, an application service, and repositories backing the rule engine), but nothing in it injects @openlinker/core/<sibling> — which is what lets both invoicing and orders value-import it from a domain entity, a repository port and an application service without any risk of a CJS module-load cycle. That property is pinned by libs/core/src/__tests__/barrel-purity.spec.ts, which fails on any non-relative import of a @openlinker/core/<ctx> specifier added under the directory (with the three authorized type-only exceptions documented there - @openlinker/core/orders/types, .../invoicing/types and .../fiscalization/types, every one of them a <ctx>/types sub-barrel carrying no runtime value); @nestjs/* / typeorm / node:* imports are unrestricted. The concern now has an ordinary sales-documents.tokens.ts — the <ctx>.tokens.ts exception (engineering-standards.md § Symbol DI Token Re-export Convention) applied only while the concern had no DI bindings to discover, and ended with #2170.
The orders --> fiscalization edge is #2516’s: the per-order sales-document projection (ADR-065) reads the fiscal-registration records through IFiscalRegistrationService, exactly as it reads the invoice records through IInvoiceService. It is one-way and cycle-free by the same reasoning orders --> invoicing already is: FiscalizationModule imports InvoicingModule and neither imports an orders module, so OrdersModule importing FiscalizationModule closes nothing. Note this is a static module edge, unlike InvoiceService’s and AutoIssueTriggerService’s lazy ModuleRef resolution of the same service - those two live INSIDE invoicing, where a constructor dependency would reverse the fiscalization -> invoicing direction; a caller in orders has no such constraint.
The analytics --> orders edge (#2468) reuses CoverageResolutionStatus / CoverageResolutionStatusValues from @openlinker/core/orders/types (the cycle-breaker sub-barrel, not the main barrel) rather than minting a parallel lifecycle union, so the ledger and the detector that reports on it cannot drift. The value export (isCoverageResolutionStatus’s runtime guard needs the array) still emits a real require('@openlinker/core/orders/types') — the edge is narrower than the main barrel would give it (no OrdersModule, no ORM entities, no repository ports reachable through it), not type-only. It is one-directional — nothing in orders imports analytics. That direction is deliberate rather than incidental: the tax-inclusion setting (#2461/#2469) is read by the orders net-sales SQL, and threading it in as a plain boolean parameter from the apps/api layer (exactly as currentReportingCurrency already is) is what keeps it so. An orders --> analytics edge for that one flag would have created a needless analytics ↔ orders cycle, and would also have made the setting cacheable somewhere — the opposite of the “the toggle changes the very next query” guarantee it exists to provide.
The orders ↔ customers, listings ↔ inventory (the latter added for #824), orders ↔ invoicing (#1120), and invoicing ↔ fiscalization (#2157, the cross-document-kind one-document-per-order guard) pairs show up as cycles at the barrel level. They’re safe at runtime because the cross-context surface is interfaces, Symbol tokens, and type imports — there’s no value-level cycle between concrete classes. The NestJS module-graph back-edges (inventory → listings type/token-only; invoicing → orders via the @openlinker/core/orders/types sub-barrel that omits OrdersModule) avoid DI cycles. invoicing ↔ fiscalization is asymmetric in HOW it avoids the cycle rather than symmetric like the others: FiscalizationModule imports InvoicingModule in its imports: [...] (a real, one-way NestJS module edge — FiscalRegistrationService takes IInvoiceService via ordinary constructor DI) and consumes invoiceIssueLockKey / INVOICE_ISSUE_LOCK_TTL_MS so both write paths serialize under the identical per-order lock; InvoiceService resolves IFiscalRegistrationService lazily via ModuleRef.get(..., { strict: false }) instead of a constructor dependency, so InvoicingModule never imports FiscalizationModule back — the TS barrel-level import exists in both directions, but only one direction is a static Nest module edge. The same shape would be true of any future cyclic pair: cycle safety is a property of the contract surface, not the file-level dependency graph.
Enforcement
Section titled “Enforcement”scripts/check-cross-context-imports.mjs runs under pnpm check:invariants (chained into pnpm lint). On any cross-context import that doesn’t match the allow shapes — or that matches a deny shape — it fails the build with a file:line and the rule that fired.
Pre-existing cross-context repository-port couplings are allow-listed in the script’s ALLOW_LIST map by (file, symbol) pair until they’re rewired through service interfaces:
- Core-to-core — none left; the last eight rows went with #2791 (see below). Tracked as #718.
- Plugins + apps (112
(file, symbol)entries across 99 files) — tracked in #722. - Two permanent rows with no rewire target -
apps/api/test/integration/paginated-total-split.int-spec.ts(#2944), an integration test that asserts five repositories’ paged and counted reads agree, andapps/api/test/integration/offer-dead-failed-creation.int-spec.ts, which pins the offer-mapping dead-creation predicate against real Postgres. Both must reach the repository ports directly, are labelled as such in the script, and must NOT be swept when #722 lands.
The per-symbol gate means new deny-pattern imports added to an already-listed file still fail the build — only the specific repository-port name listed against the path is silenced. When a rewire ships, its allow-list entries drop alongside — and since #2791 that is enforced rather than remembered: a row naming a file that no longer imports the symbol (or that no longer exists) fails the build. Eight such rows had outlived their rewire under #718, which left four core services — inventory-query, order-item-ref-resolver, offer-mapping-sync and offer-builder — free to silently re-acquire a products repository port with the gate still reporting green. A stale row is not a harmless leftover: it is a standing licence for the coupling to come back.
The rule applies to every consumer of @openlinker/core/<ctx> barrels under the walked scopes:
libs/core/src/<ctx>/**— core-to-core seam (#713/#721).libs/integrations/<plugin>/**— every plugin’s runtime, fixtures, and__tests__/(#719).libs/oms/src/**— the first-party OMS product package (#2390). Not underlibs/integrations/(ADR-055 — theintegrations-*prefix would read as “an adapter to somebody else’s OMS”), but an integration-shaped consumer of core and held to the identical contract, so the walker classifies itkind: 'product'and, exactly like a plugin, never applies the same-context skip.apps/{api,worker}/**— host apps, includingsrc/**andtest/integration/**(#719).
libs/plugin-sdk/src/** is currently out of scope (no deny-pattern violations) but follows the same contract; if a violation surfaces a one-line walker addition closes the gap. apps/web/**, libs/shared/**, and libs/test-kit/** don’t import from @openlinker/core/* and stay outside the walker.
Same-context skip applies only when the importer is under libs/core/src/<ctx>/ — plugins, libs/oms and apps have no counterpart context, so every @openlinker/core/<ctx> import from those scopes is by definition cross-context.
Module Organization
Section titled “Module Organization”Monorepo Structure
Section titled “Monorepo Structure”openlinker/├── apps/│ ├── api/ # Main NestJS API Application│ │ ├── src/│ │ │ ├── main.ts│ │ │ ├── app.module.ts│ │ │ ├── auth/ # Authentication & Authorization│ │ │ ├── sync/ # Synchronization orchestration│ │ │ └── integrations/ # Integration modules│ │ │ ├── allegro/│ │ │ └── prestashop/│ │ └── package.json│ ││ └── worker/ # Background Workers (one artifact, four roles — #2279)│ └── src/│ ├── roles/ # OL_WORKER_ROLE resolution + the singleton lease│ ├── sync/ # `jobs` role: intake, runner, handlers│ ├── events/ # `events` role: domain-event stream consumers│ ├── scheduler/ # `scheduler` role: cron tasks (fleet singleton)│ └── maintenance/ # `maintenance` role: stuck-job recovery│├── libs/│ ├── core/ # Core Bounded Contexts│ │ ├── src/│ │ │ ├── products/│ │ │ ├── inventory/│ │ │ ├── orders/│ │ │ ├── listings/│ │ │ ├── identifier-mapping/│ │ │ ├── sync/│ │ │ └── events/│ │ └── package.json│ ││ ├── oms/ # @openlinker/oms — the first-party OMS│ │ # product package (ADR-055). Peer of│ │ # core, NOT an integrations entry.│ ├── shared/ # Shared Utilities│ │ ├── src/│ │ │ ├── logging/│ │ │ ├── config/│ │ │ ├── errors/│ │ │ └── types/│ │ └── package.json│ ││ └── integrations/ # External Integrations (Optional)│ ├── ai/│ ├── allegro/│ ├── dpd-polska/│ ├── eparagony/│ ├── erli/│ ├── fx/│ ├── infakt/│ ├── inpost/│ ├── ksef/│ ├── prestashop/│ ├── shoper/│ ├── subiekt/│ ├── subiekt-nexo/│ └── woocommerce/│├── schema.yaml # Unified Data Schema (OpenAPI)├── pnpm-workspace.yaml└── package.jsonThe OL-OMS ships as a credential-less connection-backed plugin (ADR-055, #2405). getCapabilityAdapter(connectionId, capability) is the codebase’s only resolution path, so OpenLinker’s own OMS gets a real Connection row rather than a registry-only shape — anything unreachable through that path forces a second resolution branch at every call site. @openlinker/oms therefore registers an ordinary AdapterPlugin (platformType: 'openlinker', adapterKey: 'openlinker.oms.v1') through the same seam as any third-party plugin: no privileged path in core. Four properties are load-bearing. (1) The row is created when an operator ENABLES the OMS, and never seeded by a migration — ADR-055 calls this the single highest-risk mechanical detail in the design and the zero-config non-negotiable, because a seeded row would enter every existing install’s authority candidate sets and flip previously-single-candidate selections to ambiguous, silently stopping working behaviour on upgrade. It is pinned by apps/api/test/integration/oms-connection-never-seeded.int-spec.ts, which builds the real migration chain in a second database rather than using the harness — the harness’s schema comes from TypeORM synchronize and runs no migrations, so a harness-based zero-rows assertion is a check that cannot fail. (2) The credential guard is relaxed CAPABILITY-WISE, via a new advertised AdapterMetadata.requiresCredentials (absent means true, resolved through resolveRequiresCredentials), never by a privileged platformType === 'openlinker' check — which would privilege one plugin by name and be unavailable to a third-party OMS adapter. It relaxes only the neither supplied arm: supplying both credentials and a credentialsRef stays a 400 at every setting, since letting it through would encrypt and persist a credential row nothing ever reads while discarding the caller’s own ref. (3) The row carries credentialsRef: '', the shipped Subiekt precedent, resolved only if (credentialsRef) — null would need a nullable-column migration and would throw at the two unguarded .startsWith('db:') sites (connection-response.dto.ts, which runs per row on every list render, and ConnectionService.updateCredentials). (4) A capability name enters supportedCapabilities together with the adapter that delivers it (the Erli #980 precedent), so the manifest shipped empty at #2405 and gained FulfillmentExecutor at #2409 alongside OlFulfillmentExecutorAdapter - see § Fulfillment Authority for that adapter. Core services reach the plugin through the descriptor’s own factory closure (createOmsPlugin({inventoryQuery, orderRecords, products, shipping, mappingConfig}), all I*Service) and HostServices is not widened (ADR-062 decision 4) — five OMS-specific reads fail that bag’s “every plausible future plugin needs this” test, and the property is asserted rather than promised (libs/oms/src/__tests__/host-services-not-widened.spec.ts). Those deps are declared and not yet injected: ShippingModule and MappingsModule are absent from the worker’s shared spine and enter only under the jobs role, so injecting them would drag providers into every other role and breach ADR-051’s guarantee that a role which is off contributes no providers. ADR-062 also records the wider posture — in v1 third-party is the contract target, not the trust target.
Capability Assignment (Implicit Capabilities)
Section titled “Capability Assignment (Implicit Capabilities)”OpenLinker uses implicit capabilities: capabilities are declared in code via adapter metadata, not stored in a database. Adapters are resolved per-connection at runtime.
Key Principles:
- ✅ Per-Connection Resolution: Each connection resolves its adapter independently
- ✅ Code-Driven Capabilities: Adapters declare supported capabilities in code (via Adapter Registry)
- ✅ Multiple Connections Per Capability: Multiple connections can support the same capability (e.g., multiple
OrderProcessorManagerconnections) - ✅ Runtime Validation: Capability support is validated at runtime when requested
Connection Entity:
// Connection represents a configured integration instance{ id: string; // UUID platformType: string; // 'prestashop', 'allegro', etc. name: string; // Human-readable name status: 'active' | 'disabled' | 'error' | 'needs_reauth'; config: Record<string, any>; // Platform-specific config credentialsRef: string; // Reference to stored credentials adapterKey?: string; // Optional explicit adapter key createdAt: Date; updatedAt: Date;}Adapter Registry (Code-Level):
Each integration module self-registers its adapter metadata via
adapterRegistry.register({...}) in onModuleInit (#570/#571), mirroring
how AdapterFactoryResolverService.registerFactory works. libs/core
no longer carries platform-specific knowledge of which adapters exist —
the registry is empty on construct and populated by integration modules
at boot. The isDefault: true flag marks the platform-default adapterKey
for connections without an explicit adapterKey field.
Plugin contract (#593, @openlinker/plugin-sdk):
The framework-neutral AdapterPlugin contract decouples plugin authoring
from NestJS module composition. A plugin descriptor is a plain object with
a static manifest, an optional register(host: HostServices) for side
registrations (connection tester, retry classifier, scheduler tasks, email
normalizer, webhook provisioner), and a createCapabilityAdapter(connection, capability, host) factory. The HostServices bag carries the curated set
of services every plugin can rely on: logger, identifierMapping,
credentialsResolver, optional cache, plus typed handles to the 8
well-known registries (the 7 originals plus the auth-failure classifier
registry, #819 — see ADR-008).
Plugin-specific cross-package ports (e.g.
CustomerIdentityResolverPort, IMappingConfigService) are passed into
the descriptor’s constructor closure — they’re intentionally not in the
host bag, to keep the contract surface lean.
In-tree plugins (Allegro, PrestaShop) keep their @Module decorator and
their own NestJS providers (TypeORM repositories, provisioners, …); their
onModuleInit body builds the descriptor + a HostServices bag from
injected fields and routes registration through the descriptor. The
createNestAdapterModule(plugin) helper at @openlinker/plugin-sdk is
the easy path for plugins that don’t need any plugin-specific Nest
providers — it produces a DynamicModule that wires HostServices from
DI and delegates registration to the descriptor.
Each app composes the integration modules it ships with via a top-level
plugin list, then hands the list to PluginRegistryModule.forRoot({ plugins })
(#572). Apps no longer hard-code per-plugin names in their NestJS module
graph — the registry is the single seam an OSS contributor edits to enable
a third-party plugin. The adapter-registration mechanic itself is unchanged;
only the import seam moved.
// apps/api/src/plugins.ts — single edit point for API plugins.export const apiPlugins: PluginEntry[] = [ PrestashopIntegrationModule, AllegroIntegrationModule, AiIntegrationModule.register(),];
// apps/api/src/integrations/integrations.module.ts — composes the plugins.@Module({ imports: [ /* ... core modules ... */, PluginRegistryModule.forRoot({ plugins: apiPlugins }), ], exports: [PluginRegistryModule],})export class IntegrationsModule {}
// AllegroIntegrationModule.onModuleInit() — descriptor-driven (#593):const plugin = createAllegroPlugin({ /* plugin-specific deps */ });const host: HostServices = { /* built from @Inject'd host fields */ };host.adapterRegistry.register(plugin.manifest);host.factoryResolver.registerFactory(plugin.manifest.adapterKey, factoryAdapter);plugin.register?.(host);
// createAllegroPlugin's manifest:{ adapterKey: 'allegro.publicapi.v1', platformType: 'allegro', supportedCapabilities: ['OrderSource', 'OfferManager'], displayName: 'Allegro Public API v1', version: '1.0.0', isDefault: true,}
// PrestaShop follows the same Shape A pattern via createPrestashopPlugin().Service Usage (Per-Connection):
@Injectable()export class ProductSyncService { constructor( @Inject(INTEGRATIONS_SERVICE_TOKEN) private integrationsService: IntegrationsService, ) {}
async syncProduct(connectionId: string, productId: string) { // Get ProductMaster adapter for specific connection const productMaster = await this.integrationsService .getCapabilityAdapter<ProductMasterPort>(connectionId, 'ProductMaster');
// Use abstraction, not concrete implementation const product = await productMaster.getProduct(productId); // ... sync logic }}
@Injectable()export class InventorySyncService { constructor( @Inject(INTEGRATIONS_SERVICE_TOKEN) private integrationsService: IntegrationsService, ) {}
async syncInventory(connectionId: string, productId: string) { // Get InventoryMaster adapter for specific connection const inventoryMaster = await this.integrationsService .getCapabilityAdapter<InventoryMasterPort>(connectionId, 'InventoryMaster');
// Use abstraction, not concrete implementation const inventory = await inventoryMaster.getInventory(productId); // ... sync logic }}
@Injectable()export class OrderSyncService { constructor( @Inject(INTEGRATIONS_SERVICE_TOKEN) private integrationsService: IntegrationsService, ) {}
async syncOrders() { // Get ALL OrderProcessorManager adapters (multiple connections) const orderProcessors = await this.integrationsService .listCapabilityAdapters<OrderProcessorManagerPort>({ capability: 'OrderProcessorManager', });
// Process orders from all sources for (const { connectionId, connection, adapter } of orderProcessors) { const orders = await adapter.getPendingOrders(); // ... process orders from each connection } }}Benefits:
- ✅ Multiple Connections: Create multiple connections per platform type
- ✅ Multiple Adapters Per Capability: Support multiple
OrderProcessorManagerconnections (e.g., PrestaShop + Allegro) - ✅ No Database Config: Capabilities declared in code (type-safe, refactorable)
- ✅ Runtime Validation: Fail fast if capability unsupported
- ✅ Per-Connection Configuration: Each connection has its own config and credentials
See Also: Connections & Adapter Resolution for detailed documentation.
Data Flow
Section titled “Data Flow”1. Order Synchronization Flow (Marketplace → Shop)
Section titled “1. Order Synchronization Flow (Marketplace → Shop)”Polling Flow
Section titled “Polling Flow”Scheduled Job / Controller │ │ @Cron('*/5 * * * *') or HTTP endpoint │ Initiates order ingestion ▼OrderIngestionService.ingestOrders() │ │ Gets OrderSourcePort adapter for the connection │ (AllegroOrderSourceAdapter or PrestashopOrderSourceAdapter) ▼OrderSourcePort.listOrderFeed({ fromCursor, limit }) │ │ cursor is opaque adapter-defined — Allegro event ID, PrestaShop date_upd ▼Marketplace / Shop API │ │ Returns order-event references (externalOrderId, eventKey, occurredAt) ▼OrderIngestionService │ │ 1. Enqueues one marketplace.order.sync job per feed item │ 2. Commits nextCursor only after successful enqueue (cursor-safety guard) ▼OrdersPollHandler → OrderIngestionService.syncOrderFromSource() │ │ 1. OrderSourcePort.getOrder({ externalOrderId }) → IncomingOrder │ 2. Resolves product / variant / customer identifiers via IdentifierMappingService │ 3. Builds unified Order and dispatches via OrderSyncService │ 4. Gets OrderProcessorManagerPort adapter for the destination shop ▼OrderProcessorManagerPort (PrestashopOrderProcessorAdapter) │ │ 1. Provisions customer + addresses; creates the cart (carrier + │ delivery address), writes the per-cart shipping sidecar (#516) and │ cart-scoped specific_prices (#895). │ 2. Uses IdentifierMappingService.getExternalIds() to get PrestaShop IDs. │ 3. Creates the order through PrestaShop's canonical PaymentModule:: │ validateOrder via the OL module's HMAC-authed `importorder` endpoint │ (ADR-016 / #905) — NOT the raw webservice POST /orders, which bypasses │ validateOrder and drops the carrier + recomputes shipping (#503/#898). ▼PrestaShop API (OL module front controller → validateOrder) │ │ Returns created order ▼OrderSyncService │ │ Saves OrderMapping │ Updates sync statusReal-Time Flow
Section titled “Real-Time Flow”Marketplace API │ │ (Webhook) ▼MarketplaceAdapter │ │ 1. Maps to unified Order schema │ 2. Uses IdentifierMappingService to replace external IDs with internal IDs │ - Order ID: external → internal │ - Product IDs: external → internal ▼Event: 'marketplace.order.received' │ │ Payload contains order with internal IDs ▼OrderSyncListener │ │ Gets OrderProcessorManagerPort adapter ▼OrderSyncService.syncOrderFromEvent() │ │ Uses ProductMappingService (for product references) │ Uses StatusMappingService (for status mapping) │ Order already has internal IDs from adapter ▼OrderProcessorManagerPort (PrestashopOrderProcessorAdapter) │ │ 1. Uses IdentifierMappingService.getExternalIds() to get PrestaShop IDs │ - Product IDs: internal → PrestaShop external IDs │ - Customer ID: internal → PrestaShop external ID │ 2. Maps unified Order → PrestaShop format │ 3. createOrder(orderCreate) with PrestaShop external IDs ▼PrestaShop API2. Inventory Synchronization Flow (Master → Slaves)
Section titled “2. Inventory Synchronization Flow (Master → Slaves)”InventoryMasterPort (PrestashopInventoryMasterAdapter) │ │ getInventory(productId) ▼PrestaShop API │ │ Returns inventory data ▼InventorySyncService │ │ Finds product mappings │ Calculates available quantity ▼For each marketplace: │ │ Gets OfferManagerPort adapter for the target connection ▼OfferManagerPort.updateOfferQuantity(cmd) │ ▼Allegro API / Amazon API / etc.3. Event-Driven Flow
Section titled “3. Event-Driven Flow”External System Event │ ▼Adapter (e.g., AllegroAdapter) │ │ Emits domain event ▼Event Bus (Redis Streams) │ ▼Event Handlers │ ├─> OrderSyncListener ├─> InventorySyncListener └─> NotificationListener4. Webhook Ingestion Flow (Inbound → Durable Work Row → Sync Trigger)
Section titled “4. Webhook Ingestion Flow (Inbound → Durable Work Row → Sync Trigger)”See ADR-005 and ADR-049 decision 1 for the decision rationale.
External System (PrestaShop) │ │ POST /webhooks/:provider/:connectionId │ Headers: X-OpenLinker-Timestamp, X-OpenLinker-Signature ▼WebhookController → WebhookService │ │ 1. Validates signature (HMAC SHA256), then timestamp (replay window, │ default 120 s — see below) │ 2. Routes SYNCHRONOUSLY at ingress (InboundWebhookRoutingService): │ per-plugin WebhookEventTranslator → capability-gated │ InboundRoutingPolicy.resolve() → SyncJobRequest | ping | unroutable │ 3. Redis dedup mark (best-effort HINT, never a gate): markProcessing — │ a Redis outage cannot fail the request │ 4. THE GATE (one Postgres transaction, WebhookJobGateRepository): │ INSERT sync_jobs (status='queued', ON CONFLICT idempotencyKey DO │ NOTHING) + INSERT webhook_deliveries in its FINAL status │ ('job_enqueued' | 'received' for pings | 'deadlettered' + dlqReason) │ — a replay conflicts on (provider, connectionId, eventId) and │ short-circuits to the idempotent 202 │ 5. markDone in Redis (best-effort, post-commit) ▼sync_jobs row (status='queued') │ │ SyncJobRunner's 1 s Postgres poll (findAndLockDueJobs, │ FOR UPDATE SKIP LOCKED) picks the row up — no Redis hop, no hint needed ▼Worker processes the job │ │ Triggers "pull" sync via adapter APIs │ (Webhook payload is not source of truth)Key Design Principles:
- The work row is the spine (#2280, ADR-049 decision 1): the
sync_jobsrow commits in the SAME Postgres transaction as thewebhook_deliveriesgate row, so “delivery recorded” and “job exists” can never diverge. Redis is entirely out of the durable path at ingress — thejobs.syncstream, thejobdedup:*reservation, and theevents.inbound.webhookspublish are all gone from this flow; the runner’s existing 1 s poll is the wake-up. The pre-#2280 poison gap (webhook markedpublished, stream entry lost, redelivery bounced off the gate → silent order loss) is structurally unreachable: a failure before the transaction throws (source retries against no row), a failure inside rolls both rows back, and after commit the job is as durable as the delivery record. - Routing happens at ingress, and its failure taxonomy is explicit:
InboundWebhookRoutingServiceclassifies deterministic faults (ConnectionNotFound/ConnectionDisabled, no translator for the adapterKey, undecodable event, capability-ungated domain) asunroutable→ a durabledeadlettereddelivery row withdlqReason, replacing the Redis DLQ stream on this path; anything else rethrows so the source retries. The ADR-015 capability-translated routing contract is unchanged —InboundRoutingPolicyService.resolve()is the same policy, split fromroute()so the decision is available without the enqueue side effect. - At-least-once delivery: Postgres-authoritative dedup (#711) prevents lost events; the Redis mark survives only as a fast-path observability hint.
- The upgrade drain is GONE, and so are both webhook streams (#2300):
LegacyInboundWebhookDrainwas the one-shot boot pass that consumed the retiredwebhook-handlergroup’s pre-#2280 backlog through the new resolve→gate path, so entries stranded at the deploy did not become silent order loss. It shipped in v0.8.0 and ran at every api boot through v0.10.0 — three releases against a condition one clean boot resolves — and #2300 deleted it together withevents.inbound.webhooksandevents.inbound.webhooks.dead, which by then had no writer (#2280 removed the publisher) and no reader. A name inREDIS_STREAM_NAMESwith neither is a retention decision about nothing. The upgrade floor is v0.8.0: an operator jumping from v0.7.0 or earlier straight past #2300 never ran the drain and can still hold that backlog; the remedy and theDELthat reclaims both keys are in docs/operations/redis-stream-retention.md. - Every stream carries a declared retention bound (#2163, ADR-049):
XACKremoves an entry from a Pending Entries List, not from the stream, so a fully-acked stream still holds every entry it ever received - retention is entirely the producer’s job. Before #2163 exactly one of seven streams was bounded, and the map that bounded it lived insideRedisStreamsEventPublisher, which three of the five write sites never call (jobs.syncand both dead-letter streams write raw), so extending that map could not have reached them. The policy therefore moved up, to@openlinker/shared/redis- the one module every writer inlibs/coreand both host apps already depends on. Two properties matter more than the numbers. The default is fail-safe: an unregistered stream resolves a conservative bound rather thanundefined, so “unbounded” is no longer a reachable state. And the bound is enforced at the call site:xAddBoundedtypes itsstreamNameas theRedisStreamNameunion, andscripts/check-stream-writes.mjs(incheck:invariants) bans bare.xAdd(outside the module, so a new stream without a declared retention fails the build rather than silently inheriting the default. The one exception isxAddBoundedDynamic, used only byRedisStreamsEventPublisherbecauseEventPublisherPort.publishtakes a dynamic stream name by contract. Two streams are bounded by AGE (MINID), not count, and the distinction is a correctness one.jobs.syncbecause untiljob-intakewrites thesync_jobsrow the stream entry is the job, andRedisStreamsJobEnqueueServicesetsjobdedup:{key}with a 7-day TTL before theXADD- so a trimmed-but-unconsumed entry would be both permanently lost and un-re-enqueueable for a week, every retry returning{isExisting: true}silently. Webhook-derived job keys are stable ({platformType}:{connectionId}:{sourceEventId}), so that is a lost order whilewebhook_deliveriesstill readsjob_enqueued. An age bound is chosen because a count bound discards under exactly the load spike it was sized for - volume-correlated, and so most likely precisely when the backlog is legitimate - whereas an age bound discards only after sustained intake failure, a condition an operator can alert on. The horizon is also deliberately longer than that dedup TTL, so anything trimmed has certainly lost its key. Note what that does and does not buy: a trimmed job is un-blocked, not recovered. A re-enqueue no longer no-ops with{isExisting: true}, but nothing re-enqueues it automatically - the consumer’s recovery is PEL-based and a trimmed, never-delivered entry was never in a PEL, while a source redelivering the same webhook is stopped at the durablewebhook_deliveriesgate that outlives every TTL here. Recovery is operator-driven (docs/operations/redis-stream-retention.md); closing the gap for real is ADR-049 decision 1 — shipped for the webhook path in #2280: a webhook-derived job now commits straight tosync_jobsat ingress and never transitsjobs.syncorjobdedup:*at all, so the trim-vs-TTL interplay above still matters only for the remaining non-webhook writers of that stream.events.master.deletion.deadbecause it is the sole record that a deletion event was discarded as malformed, and FIFO-drop would discard exactly the first entries that identify an incident’s trigger. #2301 gave the same consumer (master-deletion-offer-pause) a Postgres dead-letter store, but for a disjoint set of entries, so it is not a counterpart to this stream:stream_dead_lettersreceives an entry only after it has failed recoveryMAX_RECOVERY_ATTEMPTStimes (a handler that keeps throwing), whereas a malformed event is classified on its first read, written here andXACKed in the same step - it never fails recovery, so it never reaches the table. The age bound therefore still guards the only copy of these entries, not a diagnostic window over a durable one; were malformed events ever routed tostream_dead_lettersas well, the reason would reduce to that window and should be restated here. (The contrast was originally drawn againstevents.inbound.webhooks.dead, whose dead-lettering was durable inwebhook_deliveries.status; #2300 retired that stream, so the reason now stands on its own rather than on a comparison.) Trimming is also lazy, on write: a stream already past its cap converges over many writes and an idle stream never converges, so deploying onto an existing Redis needs a one-timeXTRIM- and, becausenoevictionrefuses the veryXADDthat would trim, a Redis that boots already abovemaxmemorycannot leave that state on its own (XTRIMis not adenyoomcommand and is the way out). The one-time upgrade steps live in docs/operations/redis-stream-retention.md. Approximate (~) trimming cannot go below one macro node (stream-node-max-entries, default 100), which is whyhealthcheckuses an exactMAXLEN 1-~ 1would really retain ~100 entries. Redis itself is now started with an explicit--maxmemoryand--maxmemory-policy noeviction: the policy alone is inert without a cap (no cap means no eviction cycle, so Redis grows until the OS OOM-killer takes the container and every consumer group and PEL with it), andnoevictionis deliberate because under anyallkeys-*policy Redis can evict a whole stream key with no error surfaced to any consumer.events.sync.jobswas removed rather than bounded: it had a publisher and, in its entire life, no consumer, and the bulk retry it announced completes synchronously before publishing, so it triggered nothing and reported nothingsync_jobsdid not already hold. - Stream-consumer recovery is the consumer’s own job (#2164, ADR-049): Redis never expires a Pending Entries List entry, so an un-ACKed message is not “redelivered after a timeout” — nothing redelivers it unless a consumer asks. Before #2164 no code path in the repo read a PEL (
XREADGROUPalways usedid: '>', which returns only never-delivered entries), so a process killed between read and ACK lost its in-flight message permanently — and on the webhook path thewebhook_deliveriesrow still readpublished, making a dropped order indistinguishable from a delivered one. Three shared primitives in@openlinker/shared/redisnow close this for all three consumer groups (webhook-handler,master-deletion-offer-pause,job-intake): a stable consumer identity (resolveConsumerName→OL_WORKER_IDor hostname, neverprocess.pid— in a container PID is typically1, so replicas collided on one PEL, while outside a container the name changed every restart and a restarted process could not reach its own history); a startup drain that reads this consumer’s ownXPENDINGhistory until empty before switching to'>'; and a periodic recovery pass that re-drains own pending (a handler that throws leaves its entry un-ACKed, and the orphan half deliberately skips self-owned rows) and then claims entries orphaned by a replica that never returned, viaXPENDING ... IDLE+XCLAIM. Its idle threshold is floored well above p99 handler duration, since a reclaim that fires early steals live work and double-runs it — and the claim reply, not theXPENDINGlisting, decides what gets processed: anXCLAIMthat does not transfer returns a null element, and ACK removes an entry from the PEL but not from the stream, so trusting the listing would re-run work a live consumer still owns. They are deliberately primitives, not a base class — the three loops differ materially (job-intake dead-letters to a DB row rather than a stream; the webhook handler runs a shutdown drain), so sharing only the recovery mechanics avoids rewriting three live consumers. A recovered entry can also come back bodiless: once a stream carries a retention bound, Redis may remove the entry while its id stays in the PEL. That is classified as a distincttrimmedoutcome and ACKed to clear the dangling id — never routed into the handler’s error path, which would persist a bogus deadsync_jobsrow or a dead-letter entry describing an event that was never actually dropped. Neither half usesXREADGROUPto recover, because node-redis (v1.5.x) transforms its reply throughtransformTuplesReply, which calls.lengthon the field array and therefore throws on a trimmed entry — aborting the drain and leaving the dangling id unackable.XPENDING+XRANGEreturn an answer instead of crashing. Everything here holds to the Redis 6.2 command floor (XPENDING/XCLAIM/XRANGE), so #1396’s Valkey swap stays a retag rather than a redesign. Thewebhook-handlergroup now has NO reader at all — #2280 retired its always-on loop (routing runs at ingress; see § 4 above) and #2300 deleted the one-shot drain that had been its last one, along with the stream itself. That leaves two live consumer groups,master-deletion-offer-pauseandjob-intake, and the primitives stay exactly as load-bearing for them: both useackTrimmedandresolveConsumerNamedirectly, andtoPendingRows— whose last external caller went with the drain — remains the internal engine ofreadOwnPendingandreclaimOrphans, i.e. of the PEL recovery itself. It is live code with no external caller, not dead code. - Idempotent job enqueue: the derived
{platformType}:{connectionId}:{sourceEventId}idempotency key (ADR-049 decision 4) dedups on thesync_jobsunique index, inside the gate transaction (INSERT ... ON CONFLICT DO NOTHING+ in-transaction SELECT — never the catch-based dedup, which would abort the surrounding transaction). - One delivery may produce several jobs (ADR-049 § Amendment, 2026-10-25): a translator may return an ordered list of canonical events when one webhook stands for several changes - Shoper’s single
product.editcovers a product edit and a panel stock edit, so it yieldsmaster.product.syncByExternalIdplusmaster.inventory.syncByExternalId(the second only whereInventoryMasteris enabled). Every resolvable event becomes a job in the SAME gate transaction; the first event’s job keeps the unchanged three-part key and is the one recorded on the delivery row, and each later job’s key gains a:{jobType}suffix. The routing policy stays one-event-one-job, so every single-event plugin is byte-identical. - Webhook payload is not source of truth: Triggers “pull” jobs that fetch full data via adapters.
- Failure-recovery (#711 semantics, #2280 mechanism): the pre-#2280 compensating DELETE is retired — the transaction replaces compensation. A failure anywhere before commit means no row exists (signature/timestamp rejections still short-circuit before any insert), so the source’s retry re-enters the gate cleanly; the unique constraint never permanently blocks a legitimate retry. After commit there is nothing left that can fail durably — only the best-effort Redis
markDone, whose failure is logged and swallowed (a post-commit delete would orphan the committed job and eat the retry). webhook_deliveries= verified deliveries only + separate auth-rejection signal (#1814): an auth-rejected delivery (missing/wrong signing secret → signature verify fails) is thrown before anywebhook_deliveriesrow is written (this doc’s ADR-005 dedup-gate invariant), so that table cannot record failures.WebhookServiceinstead upserts a durable rolling counter to a distinctwebhook_auth_rejectionstable (one row per(provider, connectionId): count + first/last-rejected timestamps + last reason), non-fatally. The webhook-status projection reads it to expose a third operator-facingactivation: 'auth-failing'state — a connection whose every delivery is 401-ing is now visually distinct from one that never registered (not-registered). Precedence self-heals: a verified delivery newer than the last rejection wins (verified); a stale rejection (outside a 24h freshness window) reverts tonot-registered.webhook_deliveries.statusis monotonic (#1916, narrowed by #2280): a NEW webhook row is written once, in its final status, inside the gate transaction — thereceived → published → job_enqueuedladder collapses to a single statement andpublishedis unreachable for new rows. TheWebhookDeliveryRepository.upsertrank guard (WEBHOOK_DELIVERY_STATUS_RANK:received<published<job_enqueued< the attention-worthy terminalsrejected/failed/deadlettered) is retained: it was what let the drain advance a pre-#2280 legacy row stuck atpublishedafter the gate self-healed its job, and with the drain gone (#2300) it stays as the monotonicity rule for the remaining overlay writers — a row can still be advanced to a terminal, never walked backwards. Every other overlay column stays last-write-wins.- Event routing (#900, ADR-015; moved to ingress by #2280):
InboundWebhookRoutingServiceresolves the connection’s per-pluginWebhookEventTranslator(translate → neutralCanonicalInboundEvent) and delegates to the capability-gated coreInboundRoutingPolicy.resolve(), which mapsdomain → jobType(no platform string-matching in the interface layer). Only WHEN routing runs moved — the contract is ADR-015’s unchanged. PrestaShop order events route tomarketplace.order.sync(#902/#903). - Webhook = trigger, poll = reconciliation backstop (#904): webhooks are the low-latency primary path; PrestaShop also schedules a relaxed
prestashop-orders-poll(default every 10 min,marketplace.orders.poll) that heals missed/dropped webhooks by re-reading orders changed since thedate_updwatermark. Both paths converge on the idempotentOrderIngestionService.syncOrderFromSource(#906 lock + #909 core update-or-create), so the poll reconciles changed orders (re-pull is authoritative; last write wins) without re-creating webhook-ingested ones.
Security:
- HMAC SHA256 signature verification using raw body bytes.
- Replay protection via timestamp validation (#711): default ±120 s window, env-configurable via
OL_WEBHOOK_SKEW_WINDOW_MS, clamped to[1 s, 300 s]. Tighter is more secure; too tight breaks legitimate webhooks under NTP drift or load-balancer latency. Operators with stable clock-sync can tighten to 60 s; cloud-hosted deployments with cross-region NTP drift can loosen up to 300 s. - Replay protection via durable Postgres dedup (#711): the
uq_webhook_deliveries_event_keyunique constraint on(provider, connection_id, event_id)rejects same-event replays even if Redis is wiped or restarted between the original delivery and the replay. Failed-validation webhooks (bad signature, stale timestamp) do NOT insert a row — they’re logged-only — so the unique constraint never blocks a legitimate retry of a previously-rejected event. - Connection validation (exists, active, provider match).
Location: apps/api/src/webhooks/ (Infrastructure / Inbound Adapters)
5. Master-Deletion Event Flow (Master Sync → Event Bus → Offer Pause)
Section titled “5. Master-Deletion Event Flow (Master Sync → Event Bus → Offer Pause)”events.master.deletion (#1599, #1689) is the system’s one remaining core-domain-event stream on an inbound path — its former sibling events.inbound.webhooks was retired by #2280/#2300 (see § 4 above). Published by MasterProductSyncService / MasterInventorySyncService after a product/variant is soft-marked isStale — at-most-once, fire-after-commit (no transactional outbox; see the trade-off note under Listings § Stale-variant offer pause). Consumed by a dedicated worker stream handler (MasterDeletionToJobHandler — a consumer-group loop with ACK-after-enqueue and dead-lettering, the pattern the retired WebhookToJobHandler also used before #2280 moved the webhook path off streams entirely) that enqueues marketplace.offer.pauseStale. Unlike the webhook flow, the authoritative state is never the event itself — it’s the persisted product_variants.isStale flag, which the hourly marketplace.offer.pauseStaleSweep reconcile task re-reads directly, so a lost event is not a lost pause. See Listings § Stale-variant offer pause for the full policy.
Technology Stack
Section titled “Technology Stack”Core Technologies
Section titled “Core Technologies”- Framework: NestJS
- Language: TypeScript (strict mode)
- Database: PostgreSQL (TypeORM)
- Caching: Redis
- Event Bus: Redis Streams (initial), RabbitMQ/Kafka (future)
- Package Manager: pnpm (monorepo)
Key Libraries
Section titled “Key Libraries”- HTTP Client:
- Adapter HTTP clients: native
fetch()(Node 18+, undici) wrapped in a per-plugin client (e.g.AllegroHttpClient,InpostHttpClient,ErliHttpClient) that hand-rolls retries, rate-limit backoff, and structured logging. This is the current in-tree standard — undici’s default keep-alive pooling removes the original reason to reach for Axios (@nestjs/axios), which is no longer required for a new adapter client. - Simple HTTP calls: Native
fetch()API (Node.js 18+) - acceptable for one-off calls like OAuth token exchange
- Adapter HTTP clients: native
- Scheduling:
@nestjs/schedule(Cron jobs) - Events:
@nestjs/event-emitter(in-memory), Redis Streams (distributed) - Authentication: JWT (
@nestjs/jwt,@nestjs/passport) - Validation:
class-validator,class-transformer - Logging: framework-neutral
LoggerPort(@openlinker/shared/logging) with a console default; host apps install the NestJS-backed adapter at boot viainstallNestLogger()from@openlinker/shared/logging/nest
Development Tools
Section titled “Development Tools”- Linting: ESLint
- Formatting: Prettier
- Testing: Jest
- Type Checking: TypeScript (strict mode)
Related Documentation
Section titled “Related Documentation”- Engineering Standards - Coding standards and conventions
- AI Coding Assistant Guide - Behaviour, reasoning expectations and guardrails for AI coding assistants
- ADR-025: Erli marketplace adapter - reconciliation-first posture, static API-key auth, Allegro-ID taxonomy reuse