Skip to content

Architecture Overview

  1. High-Level Architecture
  2. Core Bounded Contexts
  3. Capability Abstractions (Business Roles)
  4. Hexagonal Architecture Structure
  5. Cross-context dependencies in core
  6. Module Organization
  7. Data Flow
  8. Technology Stack

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
┌─────────────────────────────────────────────────────────────────┐
│ 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.


The system is organized into the following core bounded contexts:

  • Responsibility: User authentication, authorization
  • Key Entities: User, Role, Permission
  • Location: apps/api/src/auth/ or libs/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 a displayName, 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_password rides on the JWT (the analytics-consent shape, #1938, so there is no per-request read), and the global PasswordChangeRequiredGuard answers 403 PASSWORD_CHANGE_REQUIRED on every route except the two that carry @AllowPasswordChangeRequired(): GET /auth/me (the frontend reads the flag there) and POST /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 demo viewer owes both, and without the pairing each gate would block the call that resolves the other. The column defaults to false, so no existing account is affected. The change-password screen and the redirect on the code are the frontend’s (#3457).
  • Responsibility: Product catalog management, product mapping between platforms

  • Key Entities: Product, ProductMapping, ProductVariant

  • Location: libs/core/src/products/

  • Capability: Uses ProductMasterPort abstraction

  • Barcode Storage: EAN/GTIN are stored on ProductVariant (not Product), 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.gpsr jsonb) 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, null means it stated the product carries none, and undefined means 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 from features, which the sync overwrites with null. Shoper is the first master to supply it; OfferBuilderService threads it onto CreateOfferCommand.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-producers and writes the product’s safety text as a TEXT block; no match, two matches or an over-long text keeps sellerDefaults with 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 uses sellerDefaults only, because UpdateOfferFieldsCommand carries no product data.

  • Catalogue prices are gross (#3737, ADR-077): Product.price / ProductVariant.price is 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 (else PS_COUNTRY_DEFAULT). It accepts that figure only when it matches net × (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 and updateShopProductPrice.
    • 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 by product_variants.priceBasisPending, set by the migration and cleared by the first priced upsert).
  • Connection-ownership guard on the staleness prune (#1904): neither product_variants nor inventory_items carries connection provenance, so the master-sync staleness sweeps (MasterProductSyncService’s markVariantsStaleExcept, MasterInventorySyncService’s pruneStaleVariants) key on the internal product id alone - correct only while ONE connection with the relevant master capability claims that id, which is what getOrCreateInternalId’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 neutral IEntityClaimService.findRivalClaimants (@openlinker/core/integrations) whether another connection claims the same internal id and has ProductMaster / InventoryMaster enabled; on a hit the prune is withheld (never staling a sibling’s live rows), no master.*.stale event is emitted, an error is logged, and the sync result reports pruneSkipped: true (the worker handler logs it with job context; outcome is unaffected). The claim lookup is one indexed getExternalIds read that short-circuits before the capability listing in the single-claimant case, and resolves capable connections via listCapabilityAdapters({ lazy: true }) so no adapter is constructed. Deliberately a detect-and-withhold guard rather than a connectionId provenance column: the collision is unreachable through any supported workflow (it needs direct identifier_mappings manipulation), 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) and master.inventory.syncAll (every 15 min) used to enumerate an entire catalog and map(...) 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 a SyncLockPort lock whose TTL covers one RUN, never one cycle. The shared shape is runBoundedSweep (apps/worker/src/sync/bounded-sweep.ts), copied from the scan-offset family (IOfferStatusSyncService / IShopStatusSyncService - {limit, offset} in, nextOffset out, 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, so SWEEP_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 partial allSettled failure 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’s reconcileId lesson). 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 old MAX_PAGES guard is deleted - it warned pagination may be truncated and then returned outcome: 'ok', so a catalog past MAX_PAGES x pageSize was 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 costs maxAttempts=10 with backoff to 6 h plus one accumulating dead row per tick). A budgeted run reports a plain outcome: 'ok' like every other sweep; the cursor is the observable that a cycle is in flight. #2219 additionally gives IdentifierMappingQueryPort.listExternalIdsByConnection an optional page argument (return type stays string[]) - the inventory sweep reads OL’s own mappings rather than the platform, and that read was a bare unbounded find({entityType, connectionId}). Optionality is what keeps the ~12 plugin sites that type-depend on IdentifierMappingPort source-compatible. A paged read is ordered by externalId, 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.getProducts had always done display=full plus 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 one master.product.syncByExternalId per product, and getCapabilityAdapter constructs 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 enqueues master.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-capability BulkProductReader.prefetchProducts warms whatever the adapter caches and returns nothing; IMasterProductSyncService.syncFromMasterByExternalIds then 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-product business_failure / master_deleted label. That costs nothing real: this path is fed by an enumeration read FROM the master, which is structurally blind to a deletion (decision 2), and master.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 no sort, so offset paging trusted whatever order MySQL returned. (5) The budget was re-derived, and deliberately not raised further. BATCHED_SWEEP_BUDGET_DEFAULT = 500 items 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 the bulk lane, not realtime: 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 of realtime in the first place. So OL_LANE_BULK_SCOPE_CAP governs 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.syncAll reads 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 own Product identifier mappings (paged via listExternalIdsByConnection, #2219), filters synthetic variant ids, and enqueues master.product.syncFromSweep (the existing per-product sync body; #2594 split the type so the child lands in the bulk lane rather than realtime) - so a live product re-syncs idempotently and a deleted one raises MasterProductNotFoundError into the deletion path. That is precisely the shape master.inventory.syncAll has always had, which is why an InventoryMaster connection already had deletion coverage and a ProductMaster-only one had none. Absence is never the signal, and must not become one: stable pagination is not guaranteed on every adapter (PrestashopProductMasterAdapter.listExternalIds sorts id_ASC since #2627; WooCommerceProductMasterAdapter.listExternalIds still sends no orderby), 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 fourth SweepKind) 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 existing ProductMaster install picks it up on deploy as new recurring platform load. Hourly, and not capability-gated to connections lacking InventoryMaster - 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 spans ceil(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 actionProductDelete and writes a product.deleted row 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 the product domain like any other product event, it routes to the same master.product.syncByExternalId job, and the master answering not-found is still the authority that stales the variants, emits master.product.stale and 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 as master_deleted, 3 of 3 variants isStale. 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 that master.product.reconcile is 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 ProductMaster adapter can now say that it enumerates only what changed since a watermark, via the ModifiedProductLister sub-capability (listExternalIdsModifiedSince) - the first entry in a new products/domain/ports/capabilities/ directory. master.product.syncDelta consumes it: same runBoundedSweep budget/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.syncAll keeps 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 from CoreCapabilityValues, narrowed off the dispatched ProductMaster adapter. Advertising it would be harmless in itself, but a connection’s enabledCapabilities is 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, never since = lastRunAt) - so a resuming tick recomputes the same since and 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 live orderby=modified asc set 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’s date_modified (WC #19562) and stock is not on this rung at all (update_product_stock() bypasses wp_update_post()). Shoper declares it too, over its edit_date filter, with its own blind spots (a variant deleted through the API, an image change and a stock return do not bump edit_date) and results ordered by product_id rather 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’s date_upd untouched while a product write moves it, and neither ps_product_attribute nor ps_stock_available carries 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 no InventoryMaster in the tree can implement one.

  • Responsibility: Inventory synchronization, stock level management

  • Key Entities: Inventory, InventoryAdjustment, InventoryMapping

  • Location: libs/core/src/inventory/

  • Capability: Uses InventoryMasterPort abstraction

  • Variant-keyed master inventory (#822, ADR-010): MasterInventorySyncService keys each inventory_items row 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-supplied Inventory.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) — one Inventory per variant — and writes one variant-keyed row each. The PrestaShop adapter enumerates stock_availables for the product and emits one entry per combination (resolving each combination id to its ProductVariant), 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 the InventoryMasterPort boundary — 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). MasterInventorySyncService catches it, marks every one of the product’s inventory_items rows stale via an empty keep-set, and then delegates to the products context (IMasterProductSyncService.markProductDeletedAtMaster, #2222) rather than emitting itself - the delegate stales product_variants, emits master.product.stale, and applies its own ProductMaster rival guard, whose withheld outcome propagates back into pruneSkipped. Until #2222 this path staled inventory_items ONLY, while StaleOfferPauseService re-verifies product_variants.isStale before 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 reports masterDeleted: true; MasterInventorySyncHandler maps that to a terminal outcome: '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 emits master.variant.stale; an empty-but-successful master response that stales every known row is warn-logged, since it reports masterDeleted: false / outcome: 'ok'.

  • An adjustment can say WHY, and can refuse to happen twice (#2368, design § 7.3): InventoryAdjustment gains an optional idempotencyKey and a narrowed, closed reason (return_restock | manual_correction), and adjustInventory returns an InventoryAdjustmentResult — the same Inventory plus an OPTIONAL adjustmentOutcome reporting disposition (applied | deduplicated), idempotency (honoured | unsupported | not_requested) and the master’s own appliedAt. It exists because #2370 restocks returned units THERE, in the master, since inventory_items is 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 declaring Promise<Inventory> still satisfies the signature and an existing caller reading Inventory fields is untouched — the discipline listExternalIdsByConnection’s page argument was added under (#2219), and the reason a required key was rejected outright. The ONE compatibility cost is narrowing the pre-existing reason?: 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 absent adjustmentOutcome means “not reported” and a caller MUST read it as idempotency: '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) appliedAt is the MASTER’s instant, never new Date() — the change happened in another system and OL’s clock is not a witness to it (the #2336 declinedAt rule, restated by #2367’s custody transitions); null is an honest absence rather than “now”. WooCommerce reads date_modified_gmt only, since date_modified is 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 shared CachePort for seven days (matching the jobdedup:* 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: CachePort exposes 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 shared CachePort, and adjusts by reading the current stock_availables row, computing the new total and writing the FULL resource back (landed in PR #2675; neither #2077 nor #2369 appears in any commit on main, so the PR is the citable record) — the webservice PUT takes the whole body, so the read-back row is spread and only quantity overlaid, or id_shop / depends_on_stock / out_of_stock would silently revert to PrestaShop’s own defaults; it reports appliedAt: null for the reason in (3), since the resource carries no instant, and routes the reason to the operator’s log because stock_availables has no audit field and location is 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 no shopId scoping the connection and where a silent pick would move real stock in the wrong shop; no matching row at all; and depends_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 to 0 and 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 on Connection.config.stockSafetyBuffer (JSONB — no schema/migration change) and defaults to 0, which preserves the pre-#1844 pass-through behaviour (master is authoritative, including 0). Two pure, marketplace-neutral helpers own the policy (readStockSafetyBuffer / applyStockSafetyBuffer in @openlinker/core/identifier-mapping, mirroring the parseTriggerModel config-coercion precedent). It is applied at both stock write sites and applied once per flow: the publish/create builders (OfferBuilderService, ProductPublishBuilderService) stamp the buffered command.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 by IAvailabilityService, with exactly one reader (ADR-061 decision 3): the four sites above no longer import the helpers — they call applyPublishControls({quantity, scope}), which performs the identical max(0, quantity − reserve) and additionally returns a provenance, 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 with errorCode: 'availability_unknown' and never reaches the marketplace; each builder throws the retryable AvailabilityUnknownError, deliberately not a validation exception, so ADR-007 keeps it a retry rather than a terminal business_failure). One operator-facing surface needs to display the cushion; it asks getAppliedReserve(scope) rather than re-reading the helpers. libs/core/src/__tests__/no-direct-buffer-read.spec.ts fails 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 slice IInventoryQueryService.getAvailabilityByVariantIds gained a required availableToPromise (global scope, no buffer) that the two publishing consumers — the bulk wizard’s master-stock resolve and the cancellation stock-restore — read instead of totalAvailable; null there means OL does not know, and both EXCLUDE the variant rather than fall back (a fallback publishes the un-reserved quantity; a 0 would 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 by apps/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.setInventory refused to enqueue inventory.propagateToMarketplaces for any row carrying a locationId, 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 asks IAvailabilityService.getPromisableQuantities for the variant’s available-to-promise in the global scope, 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 not channel because the destination’s stockSafetyBuffer is a Control applied exactly once downstream by InventorySyncService.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 shared updatedAt, so N enqueues collapse into one job, and one job is the correct number because that job publishes the aggregate; adding locationId would 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 retryable SyncJobExecutionError, it is not swallowed as outcome: 'ok' (log token inventory_propagation_suppressed_availability_unknown, spelling parity with #2323’s write-back arm): inventory.propagateToMarketplaces is 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 costs maxAttempts with backoff” argument does not apply to a path no cron re-drives. (4) The early !inventory return 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 on observedAt === 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, so setInventory never fires for it — MasterInventorySyncService now enqueues the same variant-keyed, location-free propagation itself when its pooled-position repair marks anything stale (best-effort, unlike setInventory’s fail-fast, because the pull’s writes are already durable; a same-tick collapse with a located setInventory enqueue is desirable, not a hazard). No adapter in this repository is affected — WooCommerce leaves locationId undefined 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.syncAll is budgeted, cursor-resumed and per-connection-locked - see the identical shape described under Products above, which both sweeps share via runBoundedSweep. Two things differ here. It enumerates OL’s own identifier mappings, not the platform, so #2219 additionally gave IdentifierMappingQueryPort.listExternalIdsByConnection an optional page argument (that read was previously a bare unbounded find({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.propagateToMarketplaces has 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): reservations is OL’s own record of what it has PROMISED, one row per (orderRecordId, orderLineId, inventoryItemId). Creating one never decrements availableQuantity - 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 on status = '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 carries orderRecordId, because orderLineId is the source-supplied id and collides across orders trivially; and the ONE foreign key is inventoryItemId -> inventory_items with ON DELETE RESTRICT. expiresAt is 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 > 0 as 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.olReservedQuantity is denormalised over the ledger and the ledger is authoritative. It sums BOTH stamps, so every reader of it must scope by atpEffect for itself (#2628 review) - the counter is the honest total of what OL has recorded, and diagnostic means “record this, let it restrict nothing”, so an atpEffect-blind reader attributes an operator-visible consequence to a hold that promises nothing. That is not theoretical on the DEFAULT omp_fulfilled topology: the marketplace ships, so OL creates no Shipment and neither of closeForOrder’s two production callers (cancellation, OL-owned dispatch) ever fires, while the expiry sweep releases nothing until order_holds is bound - so a normally-fulfilled, never-cancelled order’s holds stay held and 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 by reserveOrderInventory, leaving the order invisible to the ledger AND to shortfall detection. Both now subtract publishedReservedSum (Σ 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, because claimOne writes 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 exact atpEffect-scoping of the old one, since available - counter_total >= delta is algebraically available - 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 - two InventoryMaster connections claiming it, or two locations (#2313) under one source - EVERY line raises AmbiguousReservationPositionError and 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. Carrying sourceConnectionId through 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.consume closes an order’s held reservations once its shipment shipped, claimed at-most-once through Shipment.reservationConsumedAt - the waybillRelayedAt conditional-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 OrderSyncService creates 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 a published hold 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. IMasterAbsorbedReservationService therefore consumes ('consumed', the #3480 meaning: the reduction moved to the master) every published hold whose position’s sourceConnectionId is exactly that destination, through closeForOrder, 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 optional OrderCreateStockEffect sub-capability and answers true from lowersStockOnOrderCreate(), 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 own shopping_update_stock_on_buy setting, 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 optional OrderContactRefresher sub-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 the olReserved <= available CHECK, so the state is persistable. reservation_shortfall_episodes records it as an EPISODE with a stable occurrence id, not a self-clearing flag: a partial unique index on (orderRecordId, inventoryItemId) WHERE "closedAt" IS NULL makes 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 frozen shortQuantity would 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 explicit closedAt writes: 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), and position-stale (#2628 review). The last exists because recovered is inferred from ABSENCE from the short set, and every shortfall read filters isStale = 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 positive listStalePositionIds read, the only place isStale is consulted affirmatively; the arm sits below reservation-closed (a fact about THIS order’s hold is truer than one about the position) and above recovered. Attribution reads the same published-only set the predicate does, so a diagnostic hold 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 inversion master.product.reconcile (#2222) makes for deletions. inventory.reservations.shortfall is 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 and W2-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 - OrderHealthValues is 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 is reservationShortfalls, deliberately NOT stockAtRisk, which listings already 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, the getLatestInvoicesForOrders #1713 precedent) because otherwise the row badge is permanently undefined - 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.locationId was nominally location-aware but pointed at nothing — no location table existed anywhere in the tree. inventory_locations is 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, plus countryIso2 / postcode / optional numeric(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. ownerConnectionId is 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 is ON DELETE SET NULL (the FK_category_mappings_source_connection precedent) so an operator’s warehouse outlives the integration that stocked it. Two absences are deliberate and load-bearing. locationId IS NULL means “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-connection stockLocationOverride may supply that answer instead — applied as inventory.locationId ?? override ?? null before the row is written, with a real adapter-reported location always winning. No synthetic DEFAULT location is minted anywhere, and a persisted locationId is 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 to inventory_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. Location is deliberately absent from CoreEntityTypeValues — that union is the external-mapping vocabulary and a location has no external counterpart (externalRef is a free-text operator field, not an identifier mapping); ids are minted ol_location_* straight from formatInternalId, the ShipmentRepository shape. 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 NULL plus folding provenance into the position key): the inventory.provenance.backfill job stamps every pre-existing row with LEGACY_SOURCE_CONNECTION_ID ('legacy', declared once in inventory.types.ts and 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 an ACCESS EXCLUSIVE lock across every row of the table every published quantity derives from; the pass instead runs one UPDATE ... WHERE id IN (SELECT ... LIMIT n FOR UPDATE SKIP LOCKED) per tick, so a row mid-write by a buyer-facing setInventory is skipped rather than waited on. (2) The predicate IS the cursor, so runBoundedSweep is deliberately not called. bounded-sweep.ts itself 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), and sourceConnectionId IS NULL is 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’s SET NOT NULL failing 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 in bounded-sweep.ts from 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 is remainingNull === 0 ALONE, 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 two connection_cursors keys that are also #2325’s readiness artefact: master.inventory-provenance.completedAt:connection:{system} and master.inventory-provenance.remainingNull:connection:{system}. Deleting the completion row re-arms the drain, which is safe at any time. remainingNull is 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)): findByProductAndVariant ignored sourceConnectionId, so where two InventoryMaster connections 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 optional ProvenanceScope {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: for productVariantId/locationId both 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 provenance null as 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. Both MasterInventorySyncService call sites pass includeUnattributedProvenance: true, which is safe ONLY because the prune is unreachable unless isPruneBlockedByRivalMaster returned 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 its pruneSkipped reporting 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 unbracketed a OR b beside c OR d re-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 NULL locationId the partial unique indexes are NULL-distinct and both rows are admitted (cross-source coexistence, decision (2)), but at a NON-NULL locationId the insert is refused. That condition is PERMANENT - every retry re-runs the identical statement - so it surfaces as InventoryCrossSourcePositionConflictError plus a greppable inventory_cross_source_position_conflict log 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.sourceConnectionId threads 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 NULL means 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. MasterInventorySyncService repairs that on the write path: after the setInventory loop and after the #1904 rival check, it soft-stales the SAME source’s locationId IS NULL rows for exactly the variants it just located (markLocationlessStaleForSource, the second isStale writer on the table, reusing the #1478 mechanism - no DELETE, no updatedAt bump, the row simply leaves availability through the isStale = false filters 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) The ProvenanceScope is REQUIRED here, unlike markStaleExceptVariants’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. includeUnattributedProvenance is passed as !pruneSkipped, mirroring the prune’s invariant: claiming a NULL/'legacy' row is safe only where this connection is the sole InventoryMaster claiming 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) No master.variant.stale event. Re-locating is not a master-side deletion; emitting off this count would reach marketplace.offer.pauseStale and zero live offers for stock that is still there (#1689), so the count is kept strictly separate from pruneResult and reported as the optional MasterInventorySyncResult.pooledPositionsStaled instead. (4) Reversal was half free, and #3206 paid for the other half - isStale is 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 (markStaleExceptVariants keeps 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 one PATCH away, 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 sends locationId undefined 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.propagateToMarketplaces is 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 InventoryMasterPort adapter can report a locationId - PrestaShop only through the rarely-enabled Advanced Stock Management module, WooCommerce not at all - so every position is permanently pooled, the fulfilment router’s loadStock() skips it, and an install with active locations and routable rules still resolves every line unfulfillable. Connection.config.stockLocationOverride is one per-connection operator-supplied answer, read by the pure readStockLocationOverride (the readStockSafetyBuffer / readPricingRule config-coercion precedent) once per sync CALL rather than once per row, and applied by MasterInventorySyncService as inventory.locationId ?? override ?? null before 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. markStaleExceptVariants prunes per VARIANT, so the ordinary upsert re-creates and un-stales the pooled row while the abandoned located row stays live - and getPromisableQuantities sums across every location in global scope (#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 set locationId; the override makes it one PATCH away, so markLocatedStaleForSource - the third isStale writer, the exact mirror of markLocationlessStaleForSource, same provenance rule, same no-updatedAt-bump raw statement, same absence of a master.variant.stale emission (re-pooling is not a deletion, #1689), its own inventory.propagateToMarketplaces enqueue because the pooled row’s own write can legitimately no-op while the aggregate moves - ships with it, reported as the optional MasterInventorySyncResult.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 - ConnectionService compares against the persisted config (the #2407 rule) and refuses an unknown id or one whose location is inactive, 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, because config is replaced wholesale so every unrelated patch (including AuthorityStatusService’s preset apply) re-asserts the stored value; deleteLocation’s referential refusal counts inventory_items rows 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) and pricingRule (#1843) were implemented, tested and unreachable - apps/web carried no field for either, so they protected and priced nothing. The connection Edit page now carries a platform-neutral StockAndPricingSection beside RateLimitSection (#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) 0 and unset are different persisted states and must round-trip apart - a typed 0 is the operator saying “no reserve”, writes a real 0, and reads back as 0 - and because the form recommends exactly that, 0 is not treated as a probable typo by isPresentButInvalidStockSafetyBuffer, 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 EXPLICIT null rather than deleting the key, for the same shallow-spread reason as rateLimit (#2016), and every reader already treats null exactly like absent. (2) A margin of 100% or more is refused, not accepted and silently degraded: applyPricingRule falls 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, alongside validateRateLimitConfig’s neutral bounds check in ConnectionService.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 own superRefine, and curl and MCP bypass the browser entirely. The same neutral check bounds stockSafetyBuffer and stockZeroThreshold. 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 by scripts/check-stock-and-pricing-preview-mirror.mjs under pnpm check:invariants - a preview computed by different arithmetic than the publish path is worse than none. (3) stockZeroThreshold is the second remedy the #1844 design named, for slow-moving stock: a quantity strictly below the threshold publishes as 0. 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; 0 disables 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 the listings stock-at-risk aggregate (#1983) resolves its “publishes nothing” predicate through applyStockSafetyBuffer rather 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: OfferStockRestoreService wrote 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 threshold 0 a 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 one inventory.saleDecrement job per routed work (realtime lane, scoped to the ORDER’s source connection - never the holder every OMS-packed work shares, #2609), from both routing sites via the pure deriveSaleDecrementEnqueueIntents in the fulfillment leaf. The worker handler reads the work (IFulfillmentWorkQueryService.findWorkById) and the order record and passes them into IInventorySaleDecrementService, because inventory may not import orders and 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’s sourceConnectionId at the work’s location or pooled, never as one global InventoryMaster like 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_decrements carries a UNIQUE idempotencyKey (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 reporting idempotency: 'unsupported', whose own cache window is only a second layer. (3) An unknown outcome is never retried automatically. A claim found still pending (a run died mid-call) and any throw from the write become in_doubt; a blind retry risks decrementing repeatedly and silently, whereas declining risks one visible under-decrement. Every in_doubt is 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) is retryable - 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 every OrderSource, 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 reads locationId as a magazynId. After a successful write the master’s new quantity goes through setInventory, which enqueues inventory.propagateToMarketplaces straight away. (6) A refusal, an in-doubt line or a clamp is visible on the order, as the persisted stock-decrement-blocked attention state under the new sale-decrement producer - 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, by resolveRoutingTarget() - the selection half of the intercept - and the same answer drives both the hold’s immutable atpEffect and the intercept, so they cannot disagree. An order OpenLinker is about to route is held published whatever the ADR-012 dispatch routing says (its default omp_fulfilled would stamp diagnostic, 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 (closeForOrder with the new optional orderLineIds) when the line settles applied / deduplicated or is skipped as source-is-owner, BEFORE the mirror write, so the propagation setInventory enqueues 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. A blocked / in_doubt / retryable line 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/consumeHoldIfSettled never undo the decrement, which is already durable — they return false, and DecrementForWorkResult.holdCloseFailedLineIds carries 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 already published; 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 (bulk lane, 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 in sync_jobs under that key” and not “a line with no inventory_sale_decrements row”: 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 joins order_records and sync_jobs by 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 stale jobdedup: key cannot outlive the window - clamped 1 hour to 14 days) and a grace window (default 10 minutes): sync_jobs is pruned by #2946 (succeeded rows 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.syncBatch child per PAGE instead of one master.inventory.syncFromSweep per product, and InventoryMasterPort gains the optional BulkInventoryReader prefetch 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: getCapabilityAdapter builds 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-product master.inventory.syncFromSweep job under a batchRetry key, so that job type stays registered and keeps its own retry ladder and dead row. And the child runs in bulk (#2594/ADR-050), never realtime. The PrestaShop implementation reads stock_availables filtered by a pipe-joined id_product OR list with an explicit sort - 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. ConnectionService therefore refuses the false -> true transition with a 400 naming the remedy. Four properties are load-bearing. Enablement is decided from CONFIG, never from a capability list - the sourcing claim is config-only and FulfillmentRouter is deliberately absent from CoreCapabilityValues, because a connection’s enabledCapabilities is stamped at create and never retro-filled (#2085), so gating on a capability name would exempt every connection that already exists. The already-shipped parseAuthorityConfig is 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 (the enqueueInitialTaxonomySync rule), 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: deleteLocation and 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 is POST /inventory/locations/bootstrap, which mints one MAIN warehouse 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 in existingCodes and 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 NULL on 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’s stockLocationOverride, 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 realtime lane runs jobs concurrently and promises nothing about the order two marketplace.offerQuantity.update jobs 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 by InventoryService.setInventory for the dedupe key) end to end as UpdateOfferQuantityCommand.observedAt, and InventorySyncService refuses 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) SyncLockPort lock 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 a write_contended failure and the handler turns an all-contended result into a neutral ContendedWriteError, 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. advanceCursor is 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. observedAt is 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 ordinary connection_cursors store keyed inventory.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 says offer but also holds ShopProduct external 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: observedAt is inventory_items.updatedAt, which #2071 made database-stamped and read back via RETURNING, 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-side new 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 one updateOfferQuantitiesBatch call, 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 the fan-out lane provided. Narrowed by #2621, ADR-067 § Amendment (#2621): once AllegroOfferManagerAdapter.updateOfferQuantity returns 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): OfferStockRestoreService treats OfferStockRestorer as a participation declaration only and writes the restored quantity through IInventorySyncService.updateOfferQuantities, quoting the variant’s stockUpdatedAt as observedAt, 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): MasterInventorySyncService withholds both its post-pull prune and its full-deletion prune when a second connection with InventoryMaster enabled claims the same internal product id - see the identical guard described under Products above (one shared IEntityClaimService seam, 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. code is 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 any inventory_items row still names the location — the refusal lives in LocationService.deleteLocation, not the controller, because inventory_items carries no FK to inventory_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 with status=inactive instead, which keeps historical positions pointing at a row that exists.

  • Duplicate-position detection (#2319, ADR-058 ladder step (iii)): GET /inventory/duplicate-positions is a READ-ONLY, admin-only operator diagnostic — it detects, never repairs, and writes nothing. It groups every inventory_items row 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 NULL plus folding provenance into the position key), and must be run AFTER the #2317 backfill has drained — the full procedure, the survivor rule and the remediation DELETE live in docs/operations/inventory-duplicate-positions.md.

  • See ADR-061 for OL-owned advisory reservations and the AvailabilityAuthority capability.

  • Recording a hold — IReservationService (#2344, ADR-061 decision 1, design § 4.2 amendments 1/2): the order-shaped seam over #2343’s ledger, called by OrderIngestionService after persistOrder and before destination provisioning. It resolves each line’s variant to an inventory_items position, applies two gates, and hands every claimable line to claimHeld in ONE call — the sort-by-inventoryItemId deadlock 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: quantity is the DESIRED TOTAL and a repeated claim is a SUCCESS with deltaApplied: 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) atpEffect is stamped by the CALLER and is immutable per reservation, so #2345’s ATP subtraction is a local column test and no inventory ↔ fulfillment read exists on the publish path. Ingestion resolves it from IFulfillmentRoutingService: omp_fulfilled (rule or default — the marketplace ships) ⇒ 'diagnostic', ol_managed_carrier / source_brokered ⇒ 'published'. Two arms take 'diagnostic' for one reason — a processorAvailable: false rule 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 inside inventory was 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 explicit inventoryItemId raises AmbiguousReservationPositionError before anything is written — findAvailabilityByVariantIds SUMs positions while a reserve UPDATE … WHERE id = $1 takes 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 a released / consumed / expired row 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 to held), not a quantity the guard must decide atomically. (5) Zero live positions is REPORTED, not thrown ('no-position') — a variant no InventoryMaster has 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, and InsufficientAvailabilityError is 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. expiresAt is mandatory (ADR-061) and defaults to now + OL_RESERVATION_TTL_MS (7 days, clamped [1 h, 90 d]), honoured — like atpEffect — only on INSERT. Release, consume and expire land on this same interface with #2346 / #2347 / #2348. OrdersModule now imports InventoryModule, a one-way edge in the OrdersModule → InvoicingModule shape; 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 by apps/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 a PromisableQuantity whose provenance is 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 as 0, since an absent number silently written as zero is how a healthy catalogue stops selling. The formula is max(0, Σ available[live, all locations AND sources] − Σ olReserved[atpEffect='published']) with the operator’s stockSafetyBuffer applied 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.reservedQuantity is never subtracted — it mirrors the master’s own bookkeeping and is already reflected in availableQuantity, so subtracting it would double-count. And the ledger term ships empty rather than absent: ReservationLedgerReaderPort is called with a REQUIRED atpEffect argument (never defaulted — the wrong default subtracts diagnostic holds from a real quantity) against EmptyReservationLedgerReader, 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 discriminated AuthorityScope, aliased inventory-side so the zero-sibling-edge leaf never learns an inventory-flavoured spelling of its own type exists; channel (the publishing shape) and global are answered, while location / order / work throw 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_TOKEN binds ReservationLedgerReader, which sums reservations.quantity WHERE status = 'held' AND "atpEffect" = $stamp, joined to inventory_items and grouped by the position’s productVariantId. Five properties are load-bearing. (1) The stamp is a bound column test, never an inference - atpEffect is written at creation by the ingestion caller holding the routing outcome (#2344) and is immutable, so a diagnostic hold is invisible to a published read 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 = false mirrors the numerator: findAvailabilityByVariantIds excludes 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) Only held rows 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, beside computeAtp) takes a discriminated AtpAnswer: the computed arm subtracts, and the authority arm passes the answer through untouched and reports OL’s holds as the new PromisableQuantity.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 dispatched AvailabilityAuthority exists), so it is asserted at the pure-rule level rather than through the service; AvailabilityService always 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) olHeldNotReflected is null on the computed path, never 0 - the holds ARE inside quantity there, and 0 would say “no outstanding holds”, a different and usually false claim; on the authority path 0 is meaningful and means OL holds nothing the authority does not already know about. The reader refuses location / order / work as well as AvailabilityService does, and the repetition is the point: reservations carry no channel axis (a hold is a claim on physical stock, so global and channel share 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’s EmptyReservationLedgerReader is no longer reachable from any production path - it moved to @openlinker/core/inventory/testing as 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 by apps/api/test/integration/publish-quantity-parity.int-spec.ts; the predicate itself is proved against real Postgres by atp-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.expire examines held reservations past expiresAt and 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. releaseHeld is the only thing that stops a hold counting (#2345 filters status = 'held'), which is exactly why the obligation check gates the RELEASE and not some later publish; atpEffect is never rewritten, since extension moves expiresAt alone 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 - ObligationReaders is a MAPPED TYPE over ReservationObligationKindValues, so Wave 3 adding accepted-fulfillment-work cannot 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 is present > 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 NOT runBoundedSweep. The candidate set is status = '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 because expiresAt moves 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.ts draws that distinction in its own header and inventory-provenance (#2317) recorded the identical reasoning; the sweep PRIMITIVES are reused (budget clamp, lock TTL) and the offset machinery is not. No MasterSweepKind member is added - that union is master-prefixed and sweepLockKey renders master:{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 the held set never drains and the stuck state is invisible. Past OL_RESERVATION_OBLIGATION_MAX_AGE_MS (30 days, from the hold’s own createdAt - 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 its expiresAt, 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), bulk lane (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 no order_holds row 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 - releaseHeld had 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.restoreStockForCancelledOrder is 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 filters status = '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 own CloseForOrderResult as 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 that CloseForOrderResult is 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 the OfferStockRestorer short-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 durable Shipment.reservationConsumedAt claim through the new IShipmentQueryService.hasConsumedReservations - a DURABLE FACT, never an inference from reservation status, which cannot tell “consumed” from “never reserved” because listHeldByOrderRecordId returns 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. closeForOrder tolerates 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, since SyncJobRunner retries only a FAILED job and there is no stockRestoreSweep reconcile task the way #1689’s pause has one. It raises OfferStockRestoreReleaseIncompleteError instead 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 a SIGKILL anywhere leaves the job un-succeeded, the retry re-runs the whole sequence, the release closes nothing (guarded on held) 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 than void so every non-restoring exit is observable; an unknown availability is still OMITTED and never written as 0, which is the primitive #1689 uses to pause an offer. ListingsModule imports ShippingModule for the marker read - acyclic, and shipping must never import listings back.

  • 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 for OrderCreate / OrderFulfillmentUpdater and is unchanged. Alongside it, the order-lifecycle leaf (§ 21) owns a derived OrderLifecyclePhase computed from facts by the pure deriveOrderLifecyclePhase; it projects one-way onto the transport status for writeback via phaseToOrderStatus and 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/holds and POST /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 in OrderHoldService. Four properties are load-bearing. (1) The rich projection is DETAIL-ONLY. activeHold + holdHistory[] are attached by getOrder alone, never by the shared toDto that runs per row on the paged list, because listHolds is one query per order and on a list that is an N+1; activeHold is DERIVED from the same listHolds read rather than a second getOpenHold call, so the two answers cannot disagree. The list is not left blind — the shared toDto gains the single scalar activeHoldReason, 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 through IOrderHoldService.getOpenHold against order_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. HoldReleaseNoteRequiredError maps to 400 (resubmit with a note — a request problem, not a permission one) and HoldReleaseNotPermittedError to 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. release takes only a holdId while 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. IOrderProvisioningResumeService lives in OrdersModule beside OrderDestinationRetryService — whose three seams (record repository, identifier mapping, job enqueue) it shares — and deliberately NOT in the leaf OrderHoldsModule, 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 a HoldAlreadyReleasedError retry. 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), or failed. 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 because marketplace.order.sync has 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 as pending WITH the reason, so there is no slot to claim and re-flipping would erase that reason text. In the same change OrderHoldRepositoryPort.listOpenHolds was 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.
  • /orders list search, packed/open-return/tag filters, internal notes and tags (pilot epic #3507, G03): order_records gains a denormalized, diacritic-folded searchText column (deriveOrderSearchText, the DestinationCategory.searchText precedent — order number, buyer name, buyer email, every line SKU, GIN trigram index) plus a ?search= filter on GET /orders/GET /orders/count; a tracking-number query widens the same predicate via an OR-list of order ids resolved from IShipmentQueryService (orders may not import shipping, which already depends on orders, 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 new order_tags/order_tag_assignments tables, since both live in orders) round out the filter set — all ANDed with health, never a sixth OrderHealth bucket. The “open return” badge + filter (#2998) reuses the /returns list’s own all_open segment predicate verbatim via two new IReturnsService reads (listOpenReturnOrderIds for the filter, batched getOpenReturnSummariesForOrders for the Status-group badge) rather than redefining “open” a second time. order_notes (+ an append-only order_note_revisions table capturing each edit’s pre-edit text, so “an edited note keeps its previous text” holds after the overwrite) and order_column_presets (personal, plus one userId IS NULL admin-set workspace default) round out the epic’s backend; BenchParcelView gains exactly one field, packerNotes — notes flagged showToPacker (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 on internalOrderId cleared-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; and orders.export (#3534, D35) — a bulk-lane sync_jobs type whose handler (OrdersExportHandler) reads the run’s scope/filters/columns through IOrderRecordService (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 own order_exports row (the invoice_records.sourceDocument StoredDocument pattern — no new file-storage port). orders:export is a new admin/operator-only permission (D35: an export takes buyer data out of the system, so orders:read alone does not imply it). Two internalOrderId columns were mistyped uuid instead of text in the FIRST pass of this epic (order_notes, order_tag_assignments) — an internal order id is ol_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.
  • packedAt is a display fact, not a gate (#2072/#2080): order_records.packedAt records that someone marked the order packed (OL’s own bench or an external WMS through the packed endpoints, called as an operator service-account user). Nothing decides on it today, so no OL-vs-external exclusivity exists; a feature that gates on packed (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): OrderSyncService is 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 optional OrderSyncRequest.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’s assertRoutingPlanResolved, 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: MarketplaceOrderSyncHandler wraps a throw in SyncJobExecutionError and isNonRetryableError consults the PER-PLUGIN retry-classifier registry, where a core orders exception is registered nowhere and is therefore retryable — so throwing would re-run the whole of syncOrderFromSource (a live marketplace getOrder, persistOrder, identity resolution, projection updates) ~10 times and end in a dead job whose lastError blames connection configuration for what the ROUTER chose. Returning [] makes ingestion’s results.map(...) a no-op, matching the reason skipped_cancelled (#2284) and skipped_held (#2339) are distinct arms: “nothing went wrong” must not be routable into a retry. Because listCapabilityAdapters is 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 on NoOrderDestinationsAvailableException.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 only b was eligible proceeded to b alone behind a single logger.warn — an order silently under-provisioned, which is the same defect one condition over. resolveDestinations now reports its unresolved ids whether or not any resolved, so the partial case is persisted as routed-destinations-partially-unavailable rather 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: no syncStatus row is written (which is what the filtered-destination contract requires), so on /orders a 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 a syncStatus row: for routed orders fulfillmentState is fed by work-progress-derived shipments through ShipmentDispatchService, and the reservation publish-subtraction never consults fulfillmentState at 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, and FulfillmentRouter stays absent from CoreCapabilityValues (#2403). Populating the field at ingestion — the none / ambiguous / selected decision — 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 /orders as order_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 a syncStatus arm. A syncStatus row is keyed by destinationConnectionId and the entire content of the routed-to-no-destination case 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; and OrderSyncStatusFilterValues is the documented source of truth for GET /orders?syncStatus=, so widening it would widen the public query vocabulary for a value no real destination row can carry. (2) routed-to-no-destination is a DECISION and is never counted. It renders a neutral per-order badge - on the /orders row and mobile card (one shared OrderDestinationRoutingBadge, in the Status group beside health) and on the order detail page - and is excluded from DestinationRoutingAttentionReasonValues — derived by .filter from 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’s trigger-model-manual put a red “Invoicing blocked 4,312” on a healthy install. The aggregate counts against an explicit IN-list built from that array, never IS NOT NULL, so a reason written by a newer release and rolled back is not counted either. (3) The count is ANDed beside health, never folded into it. OrderHealthValues is 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 — destinationRoutingBlocked is a non-partitioning field and filter chip, the shape salesDocumentBlocked and taxRateConflict already 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 per syncOrder on every install, router or not (an IS DISTINCT FROM-guarded UPDATE that changes nothing, and bumps no updatedAt, 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 pure deriveDestinationRoutingBlock answers null when routing narrowed nothing, and the writer stores that null, so a fixed router clears the reason on the next sync; because interceptFulfillmentRouting HOLDING an order means syncOrder is never called, OrderIngestionService clears the reason on its held branch as well, or a stale routing reason would sit beside a fresh fulfillmentBlockReason. Both columns are omitted from the repository’s toOrm (the cancelledAt / #2100 single-writer precedent) and written by one narrow guarded UPDATE, so persistOrder cannot 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) detail carries 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 a text column; it carries no buyer data. The reason vocabulary is mirrored in apps/web (which cannot import @openlinker/core, #591) with one copy source per value, held identical by scripts/check-destination-routing-reason-mirror.mjs under pnpm check:invariants. Every branch is latent on every install today: no production caller populates OrderSyncRequest.destinationConnectionIds, so both columns are NULL everywhere and the change is behaviour-preserving until a router is wired.
  • A source’s listOrderFeed cursor 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 refuse date_upd as a sort/filter field on the orders webservice resource outright (code 38, “Unable to filter by this field.”, naming a shop-specific allowed-field list that omits it). PrestashopOrderSourceAdapter detects 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 by date_upd any 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): PaymentMethod is the closed online | bank_transfer | cash_on_delivery | unknown vocabulary carried on IncomingOrder / Order / OrderCreate and persisted on order_records.paymentMethod (nullable, ready-path write only like shippingAddressHash, no backfill). PaymentStatus answers has it been paid; the method answers how, and the two are kept apart because a cash-on-delivery order is legitimately cod and later paid. Five properties are load-bearing. (1) Only a platform’s own value is evidence - Allegro maps ONLINE / CASH_ON_DELIVERY / BANK_TRANSFER, PrestaShop the two core modules ps_cashondelivery / ps_wirepayment, WooCommerce the core gateways cod / bacs, Shoper is_cash_on_delivery, Erli delivery.cod; every other value, a plugin gateway or a missing field is unknown, 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 reports cod, not paid: it is created processing, 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: checkPaymentFactsConsistency flags a COD status beside a non-COD method, and persistOrder warns - 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 the cod / bacs gateway, Shoper takes payment_id from the payments mapping, and an invoice carries it as IssueInvoiceCommand.paymentMethod (inFakt cash / transfer, KSeF FormaPlatnosci 1 / 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); unknown renders 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.persistOrder denormalizes four scalars onto order_records at write time — placedAt, currency, taxTreatment (null means “not asserted by the source”, never defaulted), totalAmount — mirroring the existing dispatchByAt/fulfillmentState precedent (#927/#1108). It also writes a new child table, order_line_items (one row per order line, denormalizing sourceConnectionId + placedAt so a channel/time-bucketed per-product query never joins back to order_records), via OrderRecordRepositoryPort.upsertWithLineItems — both writes commit in one transaction, delete-then-reinsert per order so re-ingestion never leaves stale rows. Populated only for recordStatus === 'ready' records, since an awaiting_mapping/source_deleted snapshot’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’s cancelledAt column 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_CONT median, 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 a revenueShare and a coverageComplete signal reusing #2083’s getEarliestOrderDateByConnection), via the new IOrderRecordService.getSalesAndChannelAnalytics. Entirely intra-orders — no new cross-context edge. Currency (#2049/ADR-040 follow-up): revenue/averageOrderValue/medianOrderValue (headline and per channel) sum reportingTotalAmount restricted to stamped orders (reportingCurrency IS NOT NULL) — one comparable currency, never a naive cross-currency sum; currency reports which one (null when nothing in range is stamped yet). The complementary unstamped slice is surfaced via unconvertedCount/unconvertedValue (native totalAmount, informational, may mix currencies — never a KPI) rather than silently mixed in or dropped, and unconvertedCurrency labels 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 — null when 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 gross revenue, getSalesAndChannelAnalytics/getDailyOrderAggregates compute a VAT-exclusive netRevenue per bucket, plus netExcludedCount/netExcludedValue for what a stored-rate requirement leaves out. An order is net-eligible only when it is not taxRateEra = 'pre-rollout' history, carries at least one line, and — for gross-priced (taxTreatment absent/inclusive) lines — every line’s order_line_items.taxRate resolves to a known fraction (resolveNetSalesTaxRate / its SQL twin netSalesRateFractionSql, libs/core/src/orders/domain/types/net-sales-tax-rate.types.ts); net-priced (exclusive) lines need no rate, since the stored unitPrice is already VAT-exclusive. Net is gross / (1 + rate), never gross × (1 - rate) — the latter understates net by gross × 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 when TaxRateBackfillService has since written it a real rate. analytics_display_settings.include_backfilled_tax_rates_in_net_sales (read by apps/api, threaded in as a plain includeBackfilledPreRollout boolean — see the analytics --> orders cross-context-edge note above for why it is a parameter rather than an orders --> analytics import) 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 what POST /analytics/coverage/tax/rerun-backfill (coverage category C) exists to close, by actually running the backfill. No order_records row 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 both netSalesOrderNetEligibleSql and its per-line sibling netSalesLineNetEligibleConditionSql — 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 into TaxCoverageDetectionService’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 on IncomingOrder / Order / OrderCreate and is denormalized onto order_records.paymentMethod (nullable, no backfill, no CHECK; written on the 'ready' path only, read through OrderRecord.paymentMethodOrUnknown). It is orthogonal to PaymentStatus - one answers “how is it paid”, the other “may it ship” - and each source adapter maps only values it has evidence for (Allegro ONLINE / CASH_ON_DELIVERY / WIRE_TRANSFER, Erli delivery.cod, PrestaShop ps_cashondelivery / ps_wirepayment, WooCommerce cod / bacs, Shoper is_cash_on_delivery); everything else is unknown, which never pre-fills a COD amount or selects an invoice payment form. Where a source can identify COD it states codToCollect through the shared toCodToCollect (the gross total), and checkPaymentFactsConsistency flags contradictory method/status/amount combinations - logged by persistOrder, never refused. WooCommerce behaviour change: a core cod order reads cod instead of paid until WooCommerce stamps a payment date or the order is completed (cash collected), so an auto-on-paid invoicing connection does not issue for an open COD order. Write side (destination method) and the invoice payment form are follow-ups.
  • 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
  • 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 OfferManagerPort abstraction 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.sync job (pre-sync pipeline).
  • Allegro offer sync uses GET /sale/offer-events with persisted cursor key allegro.offers.lastEventId.
  • Non-blocking quantity acknowledgement (#2621): AllegroOfferManagerAdapter.updateOfferQuantity / updateOfferQuantitiesBatch no longer block on Allegro’s terminal quantity-change status — they return as soon as Allegro accepts the write (QUEUED/ACCEPTED), and only a synchronous REJECTED (a definitive answer known at submit time) still throws. The “confirm later” half is the optional PendingQuantityAckReconciler sub-capability (advertised-without-dispatch, mirroring OfferCreator/CategoryBrowser): reconcilePendingQuantityAcks(limit) reads the connection’s outstanding allegro_quantity_commands rows and re-checks each distinct Allegro commandId once — 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-configurable resolveBatchConcurrency ceiling the EAN-resolve batch path uses (ADR-047/#2229), never an unbounded Promise.all. Reconciliation never throws for an individual command’s outcome — a genuinely failed command is persisted 'failed' on allegro_quantity_commands and warn-logged, since there is no caller left waiting to catch it; only a whole-pass infrastructure error propagates. IOfferQuantityAckReconcileService (core) + the marketplace.offerQuantity.reconcile worker job + scheduler task (allegro-quantity-ack-reconcile, default ON, */2 * * * *, page limit env OL_ALLEGRO_QUANTITY_ACK_RECONCILE_PAGE_LIMIT) drive the reconcile pass per connection; the job reports outcome: 'ok' regardless of individual command outcomes, matching every other sweep-shaped job in the repo.
  • Offer status sync (#816): the marketplace.offer.statusSync job refreshes the live marketplace publication status (active | activating | inactivating | inactive | ended) of mapped offers into the offer_status_snapshots table — the steady-state counterpart to OfferCreationRecord’s one-shot creation lifecycle. It reuses the OfferStatusReader capability, 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.statusSync pass (and the same refreshOne path, #1760) also persists each mapped offer’s live marketplace price + available quantity into an offer_commercial_snapshots table - the third disjoint listings snapshot table alongside offer_status_snapshots (#816) and shop_product_status_snapshots (#1845). No second marketplace call: OfferStatusReadResult carries an optional neutral commercial observation that Allegro and Erli each project off the offer/product resource their getOfferStatus already fetched. Calling OfferReader.getOffer alongside would double the per-offer request count, and calling it instead would either push per-platform status mapping into core (MarketplaceOffer.status is 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 and null never means zero: a sparse response records “not reported” rather than a 0 an 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 of stockSafetyBuffer (#1844) and already the output of pricingRule (#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’s marketplace.offer.statusSync task, 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 the Unsynced lifecycle bucket and was invisible on the default /listings tab. The gate existed for a status-specific reason (#1063/#992: a wrong or absent Erli status field would fall through to inactive and write inactive snapshots for every mapped offer), it never applied to the commercial payload, and #2230 retired it - the sandbox swagger declares ProductResponse.status as enum ["active","inactive"], so the premise had expired. Both Erli tasks now register unconditionally and keep their per-tick enabledEnvVar, so OL_ERLI_OFFER_STATUS_SYNC_SCHEDULER_ENABLED=false restores 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. OfferStatusReadService exposes it per product (GET /listings/products/:productId/offer-status, rendered on the products drawer), and a manual POST .../offers/:externalOfferId/refresh-status force-reads one offer. Because the creation poller (#447) terminalises a record to draft/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, delayed marketplace.offer.refreshSnapshot job (~2/8/20 min) that re-reads live status via OfferStatusSyncService.refreshOne and 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_snapshots row at all — upsert had exactly two callers, both in OfferStatusSyncService — 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 the createdAt DESC scan 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 optional CreateOfferResult.publicationStatus (set only when the platform’s create response carried a status — Allegro maps publication.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.recordObservedStatus is the single write seam — it owns the snapshot port and every other upsert caller — invoked from exactly two places: unconditionally at the end of OfferCreationExecutionService.executeCreation (which also covers the previously-uncovered case where scheduleFirstPoll fails and no poll ever runs), and on the creation poller’s active terminal, where the live read just happened (scheduling a delayed re-read of what was just read was waste; POLL_TIMEOUT/draft keep their #1760 ladder). Both call sites are best-effort and never throw: loadOrCreateRecord carries no terminal-state guard, so a throw after the create would let a job retry call createOffer again — 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 the upsert to become a freshness-guarded INSERT … ON CONFLICT DO UPDATE — see the ADR-009 amendment above. Erli status coverage stays with #992 (its status read defaults unknown values to inactive, 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 job refreshSnapshot:{externalOfferId}:{attempt} against a globally unique, TTL-less sync_jobs.idempotencyKey, so one offer id could only ever receive ONE attempt-1 reconcile - across connections (an Erli externalOfferId IS the internal variant id, shared by every Erli connection), across re-creates, and across retry waves, each silently deduped against a long-dead job. A reconcileId now 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 shared buildOfferRefreshSnapshotIdempotencyKey (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 (pollOnce no-ops once the record leaves validating). (2) The per-product status read now reports mapped-but-unread offers: IOfferStatusReadService returns an OfferPublicationStatusView per offer OL has mapped to the product, with publicationStatus: null / lastStatusSyncedAt: null when 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 manual POST .../refresh-status action 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 as Not synced yet with a Check status action.
  • 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.validationErrors was, for Erli, only ever populated from a rejected status branch that could not be reached - Erli declares status as enum ["active","inactive"], nullable and has no rejection status, and statusReason is not a property of its ProductResponse at all - so validationErrors was permanently [], resolveOfferLifecycle routed every blocked Erli offer to Draft, and the Invalid bucket could never contain one. The reasons were in ProductResponse.buyableProblems (an 18-value enum) and archived, 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 own code - 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: true reports '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 bucket countByConnectionAndVariants treats as re-listable, so the offer becomes fixable through the wizard instead of being silently skipped. A status: '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, with readValidationProblems / splitOfferValidationProblems as pure helpers beside offer-lifecycle.types.ts) and persists it additively in the statusDetails jsonb: validationMessages is 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. resolveOfferLifecycle is unchanged - populating validationErrors is what moves the row into Invalid, 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 them scope: 'account' and core splits on that neutral field - a core-side list of Erli strings would have been marketplace vocabulary in libs/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__reason is single-line by design, and the full list rides in its title), 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-product OfferPublicationStatusPanel, and one connection-level Alert above 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.isStale flips true, the events.master.deletion stream (see § Data Flow) is consumed by a worker stream handler (MasterDeletionToJobHandler) that enqueues marketplace.offer.pauseStale; the core StaleOfferPauseService re-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-class OfferDeactivator capability 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 hourly marketplace.offer.pauseStaleSweep reconcile task (default ON, per OfferManager-capable connection) re-asserts the pause by reading straight from the persisted isStale flag via OfferMappingRepositoryPort.findStaleMappedVariants — the guarantee that survives a lost event. A concurrent inventory.propagateToMarketplaces run skips its offer branch for a stale variant, closing the re-raise race between the two flows. The bulk offer wizard (BulkListingSubmitService.expandVariantJobs) skips isStale siblings/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 isStale reads OrderRecord.recordStatus = 'source_deleted' (distinct from the ordinary, self-healing 'awaiting_mapping' gap) with mappingFailureReason carrying the reason — surfaced as a dedicated health bucket + list badge + detail-timeline narration, and excluded from bulk dispatch with its own ineligibility reason. A master.product.syncByExternalId job whose business_failure was caused by the deletion carries outcomeReason: 'master_deleted' on sync_jobs, rendered as a distinct jobs-list label instead of the generic “business failure”.
    • Known gap — Erli seller-frozen stock: ErliOfferManagerAdapter silently 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_variants rows tied to the deleted id stay isStale forever (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.
  • 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.filterAlreadyListed is 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-offer external.id) and fragment the auto-grouped listing (#824). Only past that guard does the execution layer (OfferCreationExecutionService) call createOffer unconditionally, so on the wizard path - the single FE entry point since #1754 - a duplicate offer is never produced. The shop path does not filter: ProductPublishExecutionService resolves the ShopProduct mapping 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, never platformType. 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 with AllVariantsAlreadyListedException -> 400 (distinct from the generic EmptyBulkSubmissionException, which stays reserved for a submission that resolved to no variants at all); if only some were, the batch proceeds and submit returns skippedAlreadyListedCount, 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[] vs body.productSet[].product.parameters[] on Allegro — the latter carries Brand, Model, Manufacturer-code, etc., and mirrors the shape Allegro returns from GET /sale/product-offers/{offerId}). The neutral CategoryParameter.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/listings exposes 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 @Injectable service classes) lives on the @openlinker/core/listings/services subpath — 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-entities sub-barrels (#594) — see docs/engineering-standards.md § Import Aliases for 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 by bulk_batch_advancements, #737), and BulkOfferCreationRetryService (#742). All four delegate to the same single-offer primitives — there’s no parallel “bulk” pipeline. BulkOfferCreationRetryService reopens a terminal-state batch: per failed record it deletes the bulk_batch_advancements row, decrements failedCount lock-stepped to the record reset, then transitions completed | partially-failed | failed → running once after the loop. The per-record V2 payload is rebuilt from the persisted request snapshot + the parent batch’s sharedConfig.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.submit treats 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 (so totalCount matches the real fan-out). Each variant-offer sources its stock.available from 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-resolved productCardId). 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 variant attributes as 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.buildCreateOfferCommand stamps a platform-neutral CreateOfferCommand.variantGroup (OfferVariantGroup = an opaque groupId shared by every sibling — today the parent OL product id — plus this variant’s distinguishing attributes, flattened from ProductVariant.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; groupId is 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 at bulk-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 their offerCreationWizard plugin-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 the TaxonomyBorrower capability (getBorrowedTaxonomy(): TaxonomyOwner, e.g. 'allegro'); OfferBuilderService threads that value + the master sourceConnectionId into CategoryResolutionService and AttributeProjectionService, which fall back from a destination-keyed lookup to the owner’s provenance-matching rows (destination_taxonomy_provenance). Capability-driven, never platformType; the downstream source:"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, so CategoryResolutionService.resolveEanMatcher resolves 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/products call 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 is lazy (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 to no-match, never to a 500 on the batch route or a failed stream, because that would make an optional upgrade worse than not having it. Owner selection is oldest-connection-wins, the same rule findBySourceCategory uses 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 because resolveTaxonomyOwner uses 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 API environment and the Allegro catalogue allegroEnvironment are 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 the allegroCategoryAccessEnabled: false toggle (#1934/F10) while producing exactly the effect the operator switched off. TaxonomyBorrower.allowsBorrowedCatalogueLookup?() is the neutral seam for saying so: optional (absent means yes), and false degrades 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 the mappings context (AttributeMappingRule entity + attribute_mapping_rules table; kind-specific data in a config jsonb, scope + target in real columns) and are read through IMappingConfigService.getAttributeMappingRules. They are scoped (all optional, AND-combined) by source connection, destination category, manufacturer (equality), and product-name phrase (substring), and ordered by priority (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-value reads product-derived AttributeProjectionInput.metadata, assembled by the buildProjectionMetadata helper at the offer-builder and product-publish-builder call sites (manufacturer derived from a well-known product feature). Operator-authored via connections/:connectionId/attribute-rules (GET/PUT/DELETE) and the connection Mappings page “Attribute Rules” tab (capability-gated to OfferManager / ProductPublisher destinations).
  • 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/price are already per-item (#1414); content was batch-shared and category placement + parameters were server-derived by the #1042 builder. #1831 adds optional per-item content / destinationCategoryIds / parameters to BulkShopPublishSubmitItemInput (+ the request DTO) that override the batch-shared / server-derived defaults, threaded end-to-end (submit → ProductPublishEnqueueService V1/V2 payload → shop.product.publish handler → ProductPublishExecutionService → ProductPublishBuilderService). Precedence in the builder: a defined per-item destinationCategoryIds (even empty ⇒ publish uncategorised) skips CategoryProvisioner provisioning; a defined per-item parameters (even empty) skips attribute projection + the required-parameter gate; per-item content (chosen at submit via item.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). ShopCategoryBrowser is a sub-capability of ShopProductManagerPort (browseCategories(parentId?) → neutral ShopCategory[], co-located isShopCategoryBrowser guard) — the shop-side sibling of the marketplace CategoryBrowser (on OfferManagerPort). Unlike a marketplace’s closed leaf-gated taxonomy, a shop lets a product be placed in any node, so ShopCategory carries no leaf flag. It is advertised-without-dispatch (declared in the WooCommerce manifest supportedCapabilities for host/FE discovery, resolved by narrowing the dispatched ProductPublisher adapter with the guard — never getCapabilityAdapter('ShopCategoryBrowser'); not in CoreCapabilityValues). The WooCommerce adapter implements it over GET /products/categories (parent-scoped, paged at the WC max per_page=100); ShopCategoryBrowseService + GET /listings/connections/:id/shop-publish/categories expose it, and a self-contained FE ShopCategoryPickerModal + useShopCategoriesQuery back the publish edit flow’s category picker (mounted by #1830). Since #2085 the capability is no longer on that read path: ShopCategoryBrowseService delegates to the DestinationCategory projection and browseCategories is reached only by the destination.taxonomy.sync job — 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 — BulkShopPublishSubmitService mirrors BulkListingSubmitService: on a mid-fan-out enqueue failure it reconciles totalCount down to what actually reached the stream, deletes orphaned pre-created ListingCreationRecords, and level-triggers the terminal-status derivation (succeeded + failed === totalCount) so a stranded running batch can never occur (previously it flipped the whole batch failed while already-enqueued children kept running). (2) Status reconcile — the steady-state shop.product.statusSync job drains one page of a connection’s published/draft records per tick via ShopStatusSyncService, reads live shop-side status through the new ShopProductStatusReader sub-capability of ShopProductManagerPort, and upserts a shop_product_status_snapshots row (neutral ShopPublicationStatus = published | draft | unpublished | removed) — the shop-side counterpart to offer_status_snapshots (#816). WooCommerce maps its native publish/draft/pending/private/trash + a 404 onto the neutral union; scheduled per WC connection (ProductPublisher-gated, hourly). (3) Retry — BulkShopPublishRetryService re-runs only the failed children, rebuilding each shop.product.publish V2 payload from a new per-item ListingCreationRecord.request snapshot (persisted at enqueue) under a wave-distinct idempotency key (mirrors BulkListingRetryService #742). (4) Duplicate handling — ProductPublishExecutionService now swallows a first-publish DuplicateIdentifierMappingError only after verifying the winning ShopProduct mapping claims the same internal variant; a divergent mapping raises ShopProductMappingConflictException instead of silently mis-linking.
  • Shoper as a publish destination (#3712): ShoperProductPublisherAdapter implements ShopProductManagerPort (ProductPublisher, opt-in - not in defaultEnabledCapabilities, since it writes to the shop). Create is POST /products (answers the bare id), update is a partial PUT /products/:id; visibility is translations.<lang>.active (Shoper’s default is draft), the language key is the shop’s default language, and category_id is REQUIRED by Shoper, so a create with no category is refused until a CategoryProvisioner (#3713) lands. Multi-variant products are refused, not approximated: Shoper variants are product-stocks rows keyed by option ids that must already exist in the shop, and creating them was not verified. Images are sent after the product exists, as POST /product-images {product_id, url} (Shoper fetches the image itself; the first becomes the main one): only to a product that has none (main_image null), so a re-publish never appends duplicates, and every image failure is a warning, never an error - the product mapping is persisted only after publishProduct returns, 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 as warnings. The description grammar was measured live: Shoper keeps rich HTML (tables, div, img, links) and strips script, iframe src and on* 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 REQUIRES parent_id on POST /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 one ShoperCategoryReader shared with ProductMaster rather than a second reader. Declared with the adapter and opt-in like ProductPublisher. Record: docs/plans/implementation-plan-shoper-category-provisioner.md.
  • Shop global-attribute read + publish linkage (#1835, ADR-024): shops previously emitted every neutral publish parameter as a per-product free-text custom attribute ({name, options}), dropping valuesIds — which cannot power storefront filtering. ShopAttributeReader is a sub-capability of ShopProductManagerPort (listAttributes() + listAttributeTerms(attributeId) → neutral ShopAttribute[] / ShopAttributeTerm[], co-located isShopAttributeReader guard) 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 dispatched ProductPublisher adapter — not in CoreCapabilityValues). The WooCommerce adapter reads GET /products/attributes + GET /products/attributes/{id}/terms, and on publish links a real global attribute when a neutral OfferParameter carries valuesIds (term ids) plus a numeric id (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 FE ShopAttributePicker (+ 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 + one products/{parentId}/variations entry per sibling — a single-variant / simple product keeps the pre-#1836 type:'simple' path unchanged. ProductPublishBuilderService populates the shop-publish sibling of the marketplace OfferVariantGroup (#1065) — PublishProductCommand.variantGroup: PublishProductVariantGroup (groupId = the OL product id, this variant’s own distinguishing attribute values, plus groupAttributeValues — the UNION of every sibling’s values per attribute name, since the parent must declare its full variation-axis option set up front) — whenever getVariantsByProductId(...).length > 1, mirroring the #1065 populate rule. ProductPublishExecutionService additionally resolves the parent’s ShopProduct mapping (keyed on groupId, distinct from the variant’s own mapping — no schema change, since product and variant internal ids are distinctly prefixed and coexist in the same ShopProduct entityType) and stamps it onto variantGroup.externalParentProductId; PublishProductResult.externalParentProductId reports 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 global pa_*, since ProductVariant.attributes are freeform) and a groupId-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 singular option, not the parent’s plural options).
  • 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_cache in apps/api (which the worker cannot reach). DestinationCategory replaces it with a neutral, core-owned projection in listings — { 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 via TaxonomyBorrower — #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 pattern product_content_field already uses). Scope resolution probes OfferManager then ProductPublisher and is capability-driven, never a platformType switch — an owner DECLARES its identity via the TaxonomyIdentityProvider sub-capability (getTaxonomyIdentity(): TaxonomyOwner), and an adapter declaring nothing resolves to null so the sync skips it instead of writing rows under a guessed owner. Wave 1 inferred that value from platformType instead; #2063 replaced the inference because platformType cannot express an axis the platform splits its tree along — every Allegro connection carries a required environment resolving 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). TaxonomyIdentityProvider is deliberately distinct from TaxonomyBorrower — the latter answers “whose mappings do I reuse?” for OfferBuilderService (#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; the CategoryBrowser / ShopCategoryBrowser capability is used only by the destination.taxonomy.sync job, which is paged and resumable and records disappearance via a syncedAt watermark 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): expandedAt records 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 the ON CONFLICT DO UPDATE never assigns expandedAt. Runs are serialized per scope by a SyncLockPort lock, 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. search is 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 normalized searchText column (diacritic-folded in application code, so odziez finds Odzież and artykuly finds Artykuły) with LIKE, accelerated by a GIN gin_trgm_ops index — deliberately not the % similarity operator, which errors where pg_trgm is 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 reassigns parentId on 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’s expandedAt guard terminates the sync on that shape; it says nothing about reading the rows it wrote, and with no statement_timeout configured an uncapped walk would pin a pooled connection indefinitely on an operator page load. Wave 2a (#2074) repointed the marketplace reads. mapping-options.controller’s source/categories and source/categories/:id/path now read the projection, and a neutral GET /listings/connections/:id/taxonomy/categories[/search] exists — the marketplace tree-browse route the model never had, plus the first HTTP surface for search. path() is derived from the same recursive CTE search uses, which removed the last live platform call from the marketplace read path; CategoryPathReader still serves the offer-build path. Two carve-outs are deliberate and worth knowing: destination/categories was NOT repointed — it resolves ProductMaster, a third taxonomy kind the projection does not model (its DTO also carries depth / active, which have no column), so Wave 3 cannot delete CategoriesCacheService until that is modelled; and ShopCategoryBrowseService delegation 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 made TaxonomySourceUnavailableException — raised by the resolveScope every taxonomy read funnels through — reachable from a second controller, so its HTTP mapping is a global TaxonomySourceUnavailableFilter (422) alongside CapabilityNotSupportedFilter (400) and ConnectionExceptionFilter, 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) surfaced search in 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 named useAllegroCategorySearch like its allegro* 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 told No 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 on CategoryTreeBrowser: only one of the three surfaces uses that primitive, and its key-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 into onSelect; deriving the path from navigation state would stamp the offer/product/mapping with a trail the category does not have. The now-unmounted category-picker.tsx that #1741 superseded was deleted in #2130. Wave 2c (#2084 + #2085) closed the last live read. ShopCategoryBrowseService now delegates to IDestinationTaxonomyService.browse and survives only as the projection→ShopCategory mapping seam, so ShopCategoryBrowser — 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 NestJS UnprocessableEntityException, and the domain TaxonomySourceUnavailableException reaches the #2074 global filter instead (still 422, but the body’s error field 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 for destination.taxonomy.sync, on ConnectionService beside the existing enqueueInitialCatalogSync, fired unconditionally at create and on the transition into active; both are best-effort and can never fail the connection write. Three details are load-bearing. (1) Create is deliberately not gated on status — resolveScope cannot 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 tests existing.status against the persisted result, never patch.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-tick taxonomy: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 because disable() 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 = 500 parent 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 property isTaxonomyUnsynced needs 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. CategoriesCacheService and its table are retired in Wave 3, and the deferred semantic half of MCP Phase 2 (browse_categories / search_categories tools, #1488) is Wave 4. The job is connection-scoped as a documented interim scaffold because SyncJob.connectionId is 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 optional parent -> allowed children content model, rewrites applied before the allowlist, block-opener and self-closing-void requirements, and a byte cap; applyDescriptionFormat is the single pure pass that enforces it, beside the applyPricingRule / applyStockSafetyBuffer / checkRequiredToSell precedents. The seam is asymmetric on purpose: getDescriptionFormat() is a member of the OPTIONAL OfferFieldUpdater sub-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 the ShopProductManagerPort base 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 older libs/core satisfies isOfferFieldUpdater (whose guard tests only updateOfferFields) 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 reaches updateOfferFields without either builder) and the worker’s MarketplaceOfferFieldUpdateHandler - and a fifth is deliberately excluded: publishToMaster writes 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. applyDescriptionFormat is explicitly NOT a security boundary. The frontend holds no destination knowledge at all: GET /listings/connections/:id/description-format returns the declaration plus a declared flag and a resolvedVia naming 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 declared maxBytes, 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 with declared: 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 pinned override.overrides.categoryId ?? resolvedCategoryId at 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. productCategoryIdOf is now the single chain read by the readiness check, the submit and noCardCategoryIds, and that last consumer matters on its own, because a category pinned at the product tier previously never fetched its required-parameter schema, so allegro:needs-product-parameters could not fire and the row read ready straight into a 422. A blocker names its cause, and the effect is not a second cause: no-ean splits into no barcode and invalid barcode (add one versus correct the one you typed are different fixes), and category not set is 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 one bulk-blocker-copy.ts per 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 emits unknown-category-result rather 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 to no catalog match would assert the catalogue HAS no match for a barcode the lookup never answered about. It is modelled as its own member of a wider CategoryOutcome union 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.validateBatch is 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’s collectMissingSellerDefaultsFields gate is the first statement of createOffer and 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 - the allegro:title-too-long failure 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 by scripts/check-allegro-seller-defaults-mirror.mjs under pnpm check:invariants - it compares the sellerDefaults.* 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/web has 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 existing offerValidation.validateRow plugin 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 ADVISORY ean-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 a data-focus-field anchor - onFix already routed to setEditing, only the field was missing. Lane B (core, before the adapter call) runs the same rules over the projected half: AttributeProjectionService calls the pure checkParameterRestrictions (@openlinker/core/listings, beside checkRequiredToSell) and returns restrictionIssues, 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 carrying customValuesEnabled is 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, the allegro-title.ts / required-to-sell.ts precedent) is kept aligned by scripts/check-parameter-restriction-mirror.mjs under pnpm check:invariants. Three rules shape what may block. (1) No bound is hardcoded - every limit is read off CategoryParameter.restrictions, since a constant here would repeat the very defect being closed (the marketplace declared minLength and 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, so advisory: true chips 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 - useBulkRequiredProductParams now reports failedCategoryIds and returns the full schemaByCategory, because previously a settled-but-failed query left isLoading false 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-variant siblings-without-card block exists because Allegro binds the new card to the first sibling it accepts and rejects the rest with ProductConstraintViolationException.DataIntegrity - a product-level fact no single row can see. Deferred with reason: per-row granularity for enforceIdentifierRules (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, GPSR responsibleProducer + 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 a PublishProductCommand-shaped input (stock, weight, commerce.dimensions) — that reports RequiredToSellIssue[] (OUT_OF_STOCK / MISSING_WEIGHT / MISSING_DIMENSIONS, each carrying a severity). block gates the publish exactly like the builder’s existing price/unresolved-parameter gates; warn is a soft, operator-overridable signal. Every rule shipped today is warn — the severity field exists so a future hard-required rule (marketplace or shop) doesn’t need a shape change. ProductPublishBuilderService.buildPublishProductCommand runs the check on every shop publish and logs warn-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 on CreateOfferCommand): same RequiredToSellIssue output 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.rateLimit and no manifest defaultRateLimit (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 OPTIONAL EanCategoryMatcherStreaming.getStreamConcurrency(), returning a neutral ResolveConcurrencyCeiling (maxInFlight, source, adapterDefault), surfaced on GET /connections/:id/rate-limit-status and rendered by RateLimitSection - on both the enabled: true and enabled: false paths, the false one being exactly the connection whose ceiling was invisible (it is deliberately NOT on ConnectionResponseDto, whose toResponse runs 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 the concurrency passed 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 trusting isEanCategoryMatcherStreaming, which tests only streamCategoriesForBatchByEan: an out-of-tree plugin compiled against an older libs/core satisfies 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, so supportedCapabilities is 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; a getStreamConcurrency() that itself throws is the one case logged at warn, since reporting an already-computed number cannot fail without a defect. The operator’s config.rateLimit.maxConcurrent clamps 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_variants at product-sync time, like price) 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 exposes OfferTaxSettings.rates[] = {rate, countryCode} and GET /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 returned tax: null, and a PATCH setting the rates made the next purchase return tax: {"rate":"23.00"}). Erli’s ProductCreate/Update.taxRate is the same required enum its order items carry, and its own buyableProblems.missingTaxRate blocks 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’s force override 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 a SalesDocumentGateBlockReason, since the shipped invoicingBlockedBadge suppresses 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 numeric rates[] or Erli’s enum cannot express, and a rate the target category rejects. Those are conflicts at any coverage level, not coverage problems.

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 of apps/api by #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 every JobTypeValues member: lane-aware claiming selects rows by "jobType" = ANY(<lane membership>), so an uncovered type would otherwise be silently stranded in queued (worse than the loud markDead an unhandled type used to get). Each tick every lane with free slots claims independently via findAndLockDueJobsForLane (same FOR UPDATE SKIP LOCKED shape, additive jobType/scope arms — no schema change) and jobs run concurrently under per-(lane, scope) slot accounting, scope = connectionId today via the pure resolveJobScope seam (ADR-050 decision 3). Never strict priority: a saturated bulk lane (an operator’s 1000-child publish wave) cannot delay a queued realtime or fiscal job beyond its own lane’s availability. Scopes at their per-scope cap are excluded in the claim (their rows stay queued — 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 in apps/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.syncByExternalId is 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 the realtime lane’s per-scope slots ahead of a buyer’s order, and the catalogue was throttled to OL_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.syncFromSweep are the sweep-triggered children, registered in bulk against the SAME handler instances as their …syncByExternalId twins. 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. Consequently bulk is the first lane whose caps are measured rather than illustrative: total: 12, perScope: 8 (from 2 / 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, so OL_LANE_BULK_SCOPE_CAP should 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 (lower OL_LANE_BULK_SCOPE_CAP for a slower shop); it speeds up every other bulk job type, the offer-create and shop-publish waves and the status-sync/backfill sweeps included, which is intended but is a global change; and perScope sits deliberately below the measured ceiling because decision 4 ships no round-robin fairness, so at perScope === total one connection’s cycle could hold the whole lane. realtime and fiscal keep their untuned defaults - the point of moving the sweep child out is that the buyer-facing lane did not need raising. fan-out did have to be raised, but for the opposite reason: not a wrong lane, a wrong SCOPE (#2609, ADR-050 § Amendment (#2609)). inventory.propagateToMarketplaces was enqueued with a synthetic 00000000-0000-0000-0000-000000000000 connection id, so every stock write in the installation shared one scope, and a fan-out per-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, and fan-out defaults to total: 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) SyncLockPort lock. 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 way bulk did. The all-zero placeholder id survives as a dormant fallback, and is now readable as one (#2745): InventoryService still stamps 00000000-0000-0000-0000-000000000000 when no source connection is supplied, and MasterDeletionToJobHandler stamps it on every marketplace.offer.pauseStale job it enqueues - a job that genuinely has no owning connection. The frontend used to fetch GET /connections/:id for it, take the 404, and render Unknown, which is exactly what a deleted or broken connection renders as; it now renders System with no id and no link. The constant is hardcoded independently in all three places (the browser bundle cannot import @openlinker/core, #591), so scripts/check-system-connection-id-mirror.mjs under pnpm check:invariants fails the build if any copy drifts - otherwise the frontend special-case would silently stop firing and the row would quietly revert to Unknown. 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-boss groupConcurrency precedent ADR-050 decision 3 cites, since cross-replica enforcement is out of scope for Wave 3 (multi-replica correctness is still the existing FOR UPDATE SKIP LOCKED claim’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 the maintenance role; the legacy un-laned findAndLockDueJobs is 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 via OL_WORKER_ROLE (default all, 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 in apps/api, so scaling the api to N replicas multiplied every cron’s fan-out by N. SchedulerLeaseCoordinator competes for a singleton:scheduler lock and drives the now-idempotent SchedulerService.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 new SyncLockPort.extend compare-and-PEXPIRE). A Redis blip while holding is deliberately not treated as loss — only an explicit extend === false is, 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 carries scheduler, nothing executes if none carries jobs — so the jobs role’s boot gate matters more than before. That gate already exists as ADR-050’s assertFullLaneCoverage (every JobTypeValues member 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 setting OL_PRODUCT_SYNC_CRON on the api gets no error and no effect (documented in place in apps/api/.env.example, which still enumerates the full task inventory). Stuck-job recovery moved out of SyncJobRunner into StuckJobRecoveryService (maintenance); it needs no lease because requeueStuckJobs is a conditional UPDATE on a stale lockedAt and is idempotent across replicas. The api retains no @nestjs/schedule dependency at all — the one remaining api-side periodic task (demo-account cleanup) runs on an unref’d interval serialized by a per-tick singleton:demo-cleanup lock.

  • Two new inbound domains and one new job type (#2400): InboundEventDomainValues grows by 'fulfillment' (gated FulfillmentExecutor) and 'return' (gated OrderSource), and fulfillment.work.statusSync joins JobTypeValues on the realtime lane — 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 suggest bulk (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 makes assertFullLaneCoverage() fail at worker boot until it is registered — the guard doing its job, and the reason an uncovered type cannot be silently stranded in queued. Not to be confused with the pre-existing marketplace.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 in sync rather 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 to sync, 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 reads growing and only becomes backlogged once the wait is genuinely a day old. The floor and the measured-drain condition keep it honest about retries: a failing job is requeued to queued with a future nextRunAt and its original createdAt, 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 as deferredCount, 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 the catalog-trust standard: idle | draining | growing | failing | backlogged | unknown, where unknown means the counts could not be read (never a healthy-looking zero), and a clearance estimate is null when the queue is not converging rather than a very large number dressed up as an estimate. failing exists because only success is drain: counting dead as 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 reads failing whatever the queue depth is. A connection holding no cursor reports null and is described as normal for a webhook-fed connection, not flagged. (4) The attempt-duration mean excludes NULL lastAttemptDurationMs (#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 for sync_jobs is 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_jobs had 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_jobs retention (#2946, D16): SyncJobRetentionService (apps/worker/src/maintenance/, the maintenance worker role, ADR-051) deletes TERMINAL rows in budgeted batches - succeeded rows older than syncJobRetentionDays (Operational Settings, default 30) and dead rows older than syncJobDeadRetentionDays (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’s status parameter is typed SyncJobRetentionStatus = 'succeeded' | 'dead', so queued/running rows 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 no DELETE ... LIMIT, so each batch is DELETE ... WHERE id IN (SELECT id ... ORDER BY "updatedAt" ASC LIMIT batchSize) (1000 rows), and one hourly tick issues at most MAX_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-run SyncLockPort lease, unlike the idempotent StuckJobRecoveryService it shares the maintenance role with: this pass loops several DELETE statements per tick, so two maintenance replicas 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_updatedAt backs the oldest-first sub-select; updatedAt is the row’s last write, which for a terminal row is the transition into that status itself. The live-queue aggregate is restricted to status 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 by createdAt >= 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. deadCount is 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-intake and master-deletion-offer-pause - previously retried a poisoned entry forever, alarming once via an in-memory, per-process RecoveryAttemptTracker whose 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. Past MAX_RECOVERY_ATTEMPTS (still 10), the entry is written to the durable stream_dead_letters table (libs/core/src/events/, since it completes the consume half of this repo’s one publish/consume Redis-Streams pair) and only then XACKed - a failed write leaves the entry pending and it is retried on the next recovery pass, never silently dropped. raw_fields is stored as jsonb, exactly as XRANGE/XPENDING hand 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 the job-intake stream 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 optional RetryClassifierPort.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 core ContendedWriteError covers 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, never dead. sync_jobs.deferredTotalMs accumulates 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 reach dead. 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 when isNonRetryable said 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 into lastError, and the jobs list and detail page say deferred beside 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 writes lastAttemptDurationMs, 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. Each SyncJobHandler.execute() returns a SyncJobHandlerResult whose outcome the runner persists via markSucceeded(id, outcome) — atomic with the status flip. OfferCreationExecutionService is the first orchestrator to derive business_failure from a terminal-rejection branch; other handlers return 'ok' mechanically until they grow their own domain-failure semantics.

  • Responsibility: Event-driven communication between modules
  • Technology: Redis Streams (initial), RabbitMQ/Kafka (future)
  • Location: libs/core/src/events/

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.status gains archived, a soft delete reachable only from disabled through PATCH /connections/:id/archive, which also deletes the stored credential and clears credentialsRef to ''. Hard delete is the wrong tool for the reason ConnectionPort.disable already states: connectionId is the namespace of every identifier mapping, and seven mapping and rule tables ON DELETE CASCADE from the row. Three properties are load-bearing. (1) The exclusion lives in ONE place - ConnectionPort.list() without a status filter leaves archived rows out, so every picker, sweep, scheduler task, listCapabilityAdapters call (including includeAllStatuses) and authority read drops them with no call-site change, while get(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 through restore, which returns it to disabled: a plain PATCH may neither set archived (it would skip the credential wipe) nor change an archived row’s status (it would re-enable a connection whose credential is gone), and PUT /credentials refuses an archived row too. getAdapter refuses it with ConnectionDisabledException, so a stray job or history link fails loudly. (3) Credential re-entry after a restore goes through PUT /credentials, which mints a fresh row when the ref is empty; credentialsBacked therefore means “writable via PUT /credentials” (a stored credential, or an empty slot on an adapter that needs one) and the new credentialsStored says whether a value is actually present. Authority claims held in an archived row’s config stop 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 enumerating list() loses an archived referent while one resolved through get() keeps it, and config.masterCatalogConnectionId is list-resolved (the mapping pages find the paired catalog in the connections list), so archive() refuses with ConnectionInUseException (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, the LocationInUseError precedent (#2316). fulfillment_routing_rules, routing_decisions.routerConnectionId and the authority claims resolve through get() or capability listings and need no refusal. An adapter may forbid archiving through AdapterMetadata.archivable (absent = true, read via resolveArchivable), and the OL-OMS declares false: 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 a platformType === 'openlinker' check, for the same reason requiresCredentials is (ADR-055).

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): WebhookProvisioningPort defines install(connectionId, actorUserId?) returning a neutral WebhookProvisioningResult (webhooksConfigured, testPingTriggered, optional warning). Each integration package self-registers its adapter against WebhookProvisioningRegistryService in onModuleInit (today only PrestashopWebhookProvisioningAdapter at prestashop.webservice.v1). ConnectionService.installWebhooks is the single dispatch layer: it resolves the connection’s adapter via IntegrationsService.resolveAdapterMetadata, looks the provisioner up by adapterKey, and returns 400 if no provisioner is registered. ConnectionController.installWebhooks is 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’s x-webhook-sha1 has 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 one deriveShoperWebhookUrlToken policy 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’s verify through the optional query field. It reaches nothing else and must never be logged. Install registers every matching shop webhook with the rotated token; shoper-orders-poll backstops the window after a rotation and must stay enabled.
  • Connection config & credentials shape validation (#586 / #587): ConnectionConfigShapeValidatorPort and ConnectionCredentialsShapeValidatorPort define validate(payload): Promise<void>, throwing InvalidConnectionConfigException / InvalidCredentialsShapeException from libs/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 in plugin.register(host) — today Allegro registers a config-shape validator at allegro.publicapi.v1 (credentials shape is enforced deeper, at AllegroAdapterFactory.resolveCredentials), and PrestaShop registers both. ConnectionService.create / update / updateCredentials are the single dispatch layer: they call IntegrationsService.resolveAdapterMetadata, look the validator up by adapterKey, and short-circuit when no validator is registered (closing the previous platform-switch in apps/api/src/integrations/application/services/util/). The API maps the domain exceptions to BadRequestException({ message, errors }) at the boundary; plugins never depend on @nestjs/common. The structural ValidationErrorLike type in libs/core keeps class-validator out of the core runtime — the function consumes the shape, and plugins that opt into class-validator-based DTOs depend on it from their own package.json.
  • Static manifest export (#575): every adapter package exports its AdapterMetadata as a top-level const from the package barrel — allegroAdapterManifest from @openlinker/integrations-allegro, prestashopAdapterManifest from @openlinker/integrations-prestashop, erliAdapterManifest from @openlinker/integrations-erli (#980 — ships supportedCapabilities: []; #984/#993 add OfferManager/OrderSource together 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 read adapterKey / supportedCapabilities / version without 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 dispatchCapability table entry. Finer OfferManager sub-capabilities — Allegro’s CategoryBrowser / EanCategoryMatcher (#1367), Allegro’s OfferCreator / OfferEventReader and Erli’s OfferCreator (#1498) — are declared in supportedCapabilities purely so host-side discovery (the connection response) can tell them apart, and are resolved only by narrowing the dispatched OfferManager adapter with its is* guard (isCategoryBrowser, isOfferCreator, …), never via getCapabilityAdapter(connectionId, 'OfferCreator') directly. Calling getCapabilityAdapter / listCapabilityAdapters with an advertised-without-dispatch name still passes the manifest gate but then fails inside dispatchCapability (a generic Error, not AdapterNotFoundException — 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 of OrderSourcePort (listReturnFeed + getReturn, ADR-060) — is the first OrderSource-family member declared this way. Same rule, same failure mode: it is resolved by narrowing the dispatched OrderSource adapter with isReturnSourceReader, never via getCapabilityAdapter(connectionId, 'ReturnSourceReader'), and it is absent from CoreCapabilityValues. The contract shipped ahead of its only implementer (Allegro, #2330, same wave), which also amended it with the optional terminalRawStatuses hint — the guard is unchanged, so an adapter compiled against the pre-amendment shape is still a full ReturnSourceReader.
  • Type-safe capability dispatch (#573): @openlinker/plugin-sdk ships dispatchCapability<T>(capability, table, pluginName). Plugin createCapabilityAdapter implementations declare a typed dispatch table { OfferManager: () => offerManager, OrderSource: () => orderSource } and the helper returns the matching adapter cast to T. The cast lives in one place rather than once per case per plugin. True compile-time enforcement of capability ↔ T is blocked by the open string-set design (#576) — plugins can register new capability names at runtime, so a closed Record<Capability, AdapterPort> keyed by literal capability names can’t exist statically. The helper is the right size for the problem: it deletes the per-case as unknown as T boilerplate 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).
  • Responsibility: Structured logging, metrics, tracing
  • Contract: LoggerPort (log/debug/warn/error) shipped from @openlinker/shared/logging. The consumer-facing Logger factory (new Logger(ClassName.name)) delegates to a process-wide active backend.
  • Backends: ConsoleLoggerAdapter is the zero-dependency default that ships in the same package, so plugins compiled against @openlinker/shared get working logs without any host wiring. The NestJS-backed NestLoggerAdapter lives on the host-only subpath @openlinker/shared/logging/nest; hosts call installNestLogger() at the top of bootstrap() to swap it in (#589). Plugins never transitively pull @nestjs/common through the logger.
  • Tracing: OpenTelemetry (future).
  • Location: libs/shared/src/logging/ (port + default + factory); libs/shared/src/logging/nest/ (Nest-backed adapter, host-only).
  • 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 ContentPublisherPort for outbound publishing. Master path resolves ProductMasterPort via the integrations registry and calls updateProduct with a keyed patch. Channel path resolves an OfferManagerPort for the target connection, requires the OfferFieldUpdater sub-capability, walks the product’s variants → OfferMappingRepositoryPort.findMany → distinct external offer IDs, and issues one updateOfferFields per distinct offer (Allegro TEXT-section payload; idempotency key content:{productId}:{connectionId}:{publishTimestamp}).
  • AI suggestion: ContentSuggestionService composes IIntegrationsService (fetch product + variants) + IPromptTemplateService (render the offer.description.suggest template for the current channel) + AiCompletionPort (generate). Bound in the API layer (apps/api/src/content/content.module.ts) because AI_COMPLETION_PORT_TOKEN is only provided where AiIntegrationModule.register() is registered.
  • Storage: product_content_field table with two partial unique indexes (master vs channel) to honour Postgres’ NULL-distinct uniqueness for the nullable connection_id column.
  • Conflict model: optimistic — inbound reconcile sets has_conflict=true when an external version diverges while a draft is pending; re-saving the draft is treated as implicit acknowledgement and clears the flag.
  • 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). The channel axis is open-world (#580): PromptTemplateChannel = string carries any platform connection’s platformType, 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 the platformType precedent in CreateConnectionDto); the controller maps the 'master' query sentinel to null and forwards every other non-empty string verbatim. FE picker and list rendering are registry-driven via usePlugins().
  • 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 (renderTemplate pure 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 one VercelAiCompletionAdapter instance per supported provider (anthropic via @ai-sdk/anthropic, openai via @ai-sdk/openai) plus FakeAiCompletionAdapter for tests / offline dev. AI_COMPLETION_PORT_TOKEN resolves to MultiProviderAiCompletionAdapter, 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 to provider === 'anthropic' so the OpenAI request never carries a stray providerOptions.anthropic block.
  • Selection (#451 / #452): the active provider is a runtime setting persisted on the singleton ai_provider_active_setting table. Resolution: DB row → OL_AI_PROVIDER env (first-boot fallback) → 'anthropic' default. Admins switch the active provider via PUT /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 at ref = ai-provider:{provider} in the encrypted integration_credentials table; CredentialsAiProviderAdapter retains a 60 s per-provider key cache.
  • Admin surface: PromptTemplatesController at apps/api/src/ai/http/prompt-templates.controller.ts; AiProviderSettingsController at apps/api/src/ai/http/ai-provider-settings.controller.ts exposes GET /ai-provider-settings, PUT /keys/:provider, DELETE /keys/:provider, PUT /active (all @Roles('admin')). FE admin UI at /ai/prompt-templates and /ai/provider-settings.
  • Storage: prompt_templates table with four partial unique indexes honouring NULL-distinct semantics on the nullable channel column (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 in apps/worker/src/plugins.ts so the bulk-flow marketplace.offer.create handler can call ContentSuggestionService.suggestDescription() per-job for AI-generated offer descriptions. AI_COMPLETION_PORT_TOKEN resolves through PluginRegistryModule.forRoot({ plugins: workerPlugins }) in the worker’s AppModule. The suggestion binding lives at apps/worker/src/content/worker-content.module.ts (mirrors apps/api/src/content/content.module.ts) — it cannot live in libs/core/src/content/content.module.ts because that would force core to value-import @openlinker/integrations-ai, reversing the core → integration dependency direction.
  • Channel resolution and the absent fallback (#2202): channel is an exact-match key, never a hint — PromptTemplateRepository.findLatestPublished does a plain findOne on {key, channel} and PromptTemplateService.render throws PromptTemplateNotFoundException on a miss. There is no channel→master fallback anywhere in this chain. MarketplaceOfferCreateHandler resolves channel from 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) and ShopProductPublishHandler hardcodes 'woocommerce' because it only ever serves WooCommerce today. In both handlers, a platform with no published (offer.description.suggest, channel) row throws inside suggestDescription, and the handler’s own catch is 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 the allegro / prestashop / erli / woocommerce seeds 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 — the ShopProductPublishHandler (worker) calls ContentSuggestionService.suggestDescription({ channel: 'woocommerce', ... }) to fill content.description when the operator sets generateDescription on the single/bulk shop-publish submit (threaded via EnqueueProductPublishInput → ShopProductPublishPayload). Same placement + precedence as the offer flow: an explicit operator content.description override always wins (never overwritten), and any AI failure (variant lookup, missing template, LLM error) logs a warning and falls through to the master description. The offer.description.suggest prompt template is seeded per channel; #1840 adds the woocommerce seed alongside the existing allegro / prestashop rows. 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).

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 in libs/core; all national specifics are confined to the provider adapter package.
  • Key Entities: InvoiceRecord (neutral projection of an issued document), value types DocumentType, 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 in domain/ports/capabilities/, each with a co-located is* guard:
    • RegulatoryStatusReader — read the clearance status of a previously-submitted document.
    • RegulatoryTransmitter (extends RegulatoryStatusReader) — 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 neutral RegulatoryDocument blob.
    • BankAccountsReader (#1303 follow-up) — list the seller’s payable bank accounts known to the provider (neutral InvoicingBankAccount), backing the live picker for Transfer invoices.
    • BankAccountDefaultSetter (extends BankAccountsReader, #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 in rejected. Kept flat (does not extends RegulatoryStatusReader) — it backs a NATIVELY-transmitting provider (e.g. inFakt) that needs an explicit resubmit kick from OL, distinct from RegulatoryTransmitter (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.originalDocument is 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): InvoiceRecord persists a neutral issuedLineSnapshot ({ buyer, currency, lines }) whenever a document is issued (from the issue command) or corrected (the correction’s own post-correction lines) — captured in the core InvoiceService, so no adapter change. The HTTP controller assembles originalDocument from that snapshot on the document being corrected, so the originalLineNumber-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 its buyerTaxId: null + line-fidelity caveats). The returns-side consumer refuses that fallback (#2374): ReturnCorrectionProposalService reports a named no-line-snapshot outcome rather than diffing a pre-snapshot document against the order’s current state, and it targets the latest issued record via IInvoiceService.getLatestIssuedInvoiceForOrder — status-agnostic getLatestInvoiceForOrder would let a newer failed or pending row 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.buyerTaxId carries the same three-state encoding order_records.buyerTaxId uses (NULL = not asserted, '' = asserted-none, otherwise the id, read through decodeBuyerTaxIdColumn and never through a bare IS NOT NULL), and it is written once, at create, by freezeBuyerTaxIdAtIssue. 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 from cmd.buyer.taxId, i.e. from what actually went onto the document. (2) The caller’s buyerTaxIdAssertion is 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-existing hasBuyerTaxId flag (#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): AutoIssueTriggerService no longer resolves invoice-only. It lists ACTIVE connections with EITHER Invoicing or Fiscalization enabled, reduces each to a SalesDocumentRoutingCandidate via readSalesDocumentRouting(connection.config), and resolves EXACTLY ONE (documentKind, connectionId) pair via the sales-documents context’s pure resolveSalesDocumentRouting (#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 own InvoicingPort.getSupportedDocumentTypes() (decision 7’s deeper, adapter-level check); 'fiscal-receipt' has no such discovery method on FiscalizationPort yet, so the resolver’s structural check (capability enabled) is the whole validation story for that kind today. Dispatch enqueues invoicing.issue (unchanged) for 'invoice' and a new fiscalization.register job (#2156) for 'fiscal-receipt', idempotency-keyed fiscal:{connectionId}:{orderId} — the same key format the fiscalization HTTP controller already uses for a manual registration. The per-connection config.invoicing.triggerModel key 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 sets config.salesDocument.documentKind — an existing Invoicing-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 same SalesDocumentBlockOutcome reporting 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 persisted detail string 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 InvoicingPort document type - see § 16 below.
  • Tax rates are an input, never a computation (#2009 / #2246, ADR-063). A line’s taxRate arrives 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 is Accepted as of that change). It is behaviour behind a switch, not yet behaviour by default - the rule is implemented end to end, and OL_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 the InvoiceService write-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 in sales-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 through isTaxRateEnforced, 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. 0 is an answer, not a gap (export, intra-EU, exempt goods; PrestaShop already distinguishes id_tax_rules_group = 0 from 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 vocabulary InvoiceLine.taxRate already carries (23, 8, 5, 0, zw, np, oo), because rateFraction does parseFloat/100 and 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): documentType is command data the caller supplies (ADR-026 §Decision 3), so selecting it is a policy question that lives outside both document contexts - in a sales-documents policy module that is handed the order (never reading it back, so no dependency cycle with orders is 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 against pending / issued / failed-but-not-rejected records on any connection, with corrections excluded as linked follow-ups. The AutoIssueTriggerService fan-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: AutoIssueTriggerService resolves EXACTLY ONE connection via the pure resolveSalesDocumentRouting over config.invoicing.isPrimary (read by readSalesDocumentRouting; #2047 first introduced this as selectPrimaryInvoicingConnection / parseIsPrimaryInvoicing, which #2161 superseded and #2293 deleted) instead of fanning out over every Invoicing-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.issueInvoice refuses a second document when any record on a DIFFERENT connection is pending / issuing / issued, or failed with a failureMode other than rejected (InvoiceRecord.blocksIssuanceElsewhere) - only a terminal rejected means the provider definitely created nothing and another connection is free to issue. Raised as OrderAlreadyInvoicedException → HTTP 409, and as a terminal business_failure in the worker’s invoicing.issue handler (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 an issued original, not a second originating document (ADR-041 §3b), and issueCorrection is therefore not guarded. The guard is a read, so it is serialized by a per-order lock (SyncLockPort, key invoice:issue:{orderId}, TTL OL_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 reason shipmentDispatchLockKey is (#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 a pending row exists that a peer’s own guard sees. A contended attempt answers from persisted state only (already-invoiced refusal, or an issued same-key row replayed verbatim) and otherwise raises the retryable InvoiceIssueContendedException → 409, never reaching the adapter. No orderId unique 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/invoice resolves the record’s own connection, and otherInvoicingConnectionIds surfaces 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.onOrderTransition returns a neutral SalesDocumentBlockOutcome (@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) and OrderIngestionService writes it to order_records.salesDocumentBlockReason / salesDocumentUnresolvedReason / salesDocumentBlockDetail. Reporting rather than persisting is load-bearing: persisting in-place would need an OrdersModule token inside InvoicingModule, closing the runtime DI cycle the trigger’s ONE-WAY EDGE property (F3, asserted by apps/worker/test/integration/invoicing-auto-issue-boot.int-spec.ts) exists to prevent - the caller already lives in orders and 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 remaining unresolved values await the #1908 router. The write is level-triggered, not sticky: the gate re-decides on every transition and the writer stores the answer including null, 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’s toOrm (the cancelledAt single-writer precedent): persistOrder runs 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’s source_deleted treatment - 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-manual keeps 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-partitioning salesDocumentBlocked field + filter chip, not a sixth OrderHealth bucket (deriveOrderHealth returns 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 also synced), and blocked orders are not excluded from bulk issuance (POST /invoices/bulk-issue names 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 by scripts/check-sales-document-reason-mirror.mjs under pnpm 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 reports none when that record satisfies InvoiceRecord.blocksIssuanceElsewhere - the same getter the #2047 write-path guard uses. Without any check the gate was not idempotent against its own effect: a manual connection 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 testing status !== 'failed' fixes the arm a status test gets wrong in the dangerous direction: an in-doubt failure 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 terminal rejected failure (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 than failureMode, the row and timeline resolve it through invoiceSupersedesBlock, and the panel through the equivalent canRetryInvoice. They have to agree: the aggregate count and the ?invoicing=blocked filter 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 shared OrderInvoicingCell, 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) indeterminate is 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 except trigger-model-manual), because manual is parseTriggerModel’s default: on a manual install every uninvoiced order carries it, and an IS NOT NULL count 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, adapterKey ksef.publicapi.v2, registry capability Invoicing. KsefInvoicingAdapter implements InvoicingPort, RegulatoryTransmitter, and CorrectionIssuer (#1288): FA(3) VAT + KOR documents are issued and cleared through the async submit→poll→UPO model (issueInvoice), and issueCorrection is 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, adapterKey infakt.accounting.v1, registry capability Invoicing (#1281). Infakt submits to KSeF on the seller’s behalf rather than OL building FA(3) XML itself, so InfaktInvoicingAdapter implements RegulatoryStatusReader (poll the KSeF-relay clearance status Infakt reports) rather than RegulatoryTransmitter. It also implements CorrectionIssuer, RegulatoryDocumentReader, RegulatoryResubmitter (re-trigger transmission of an already-issued document — the operator “resend” action for a rejected clearance), BankAccountsReader / BankAccountDefaultSetter, plus the accounting-specific PaymentStatusReader / PaymentMarker and InvoiceEmailSender sub-capabilities. Document creation is asynchronous on inFakt’s side: issueInvoice, issueCorrection and markPaid go through inFakt’s async/*.json task 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 as failureMode: '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, adapterKey subiekt.gt.v1, platformType subiekt-gt — descended from the first InvoicingPort adapter (#753) and now carrying five capabilities (Invoicing, ProductMaster, InventoryMaster, OrderSource, OrderProcessorManager). Implements RegulatoryStatusReader, CorrectionIssuer, BankAccountsReader and BankAccountDefaultSetter. Reached through classic COM automation (ProgID InsERT.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 orderRef and finds an already-created ZK by it - an exact match on dok_NrPelnyOryg with 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’ order 1001 reaching 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 by OrderSyncService from order.id) is the key; the source’s own number moves to uwagi, 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 no orderNumber anywhere in its package, so the previous ?? '' fallback sent an EMPTY key, which the bridge’s own comment records as running unlocked and undeduped. (2) legacyOrderRef carries 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’s FindZkIdByOrderRef fallback, which searches dok_NrPelnyOryg for 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_MS is 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 inverted order-create-lock.ts’s stated precondition (“must comfortably exceed the worst-case createOrder duration”), so DEFAULT_ORDER_CREATE_LOCK_TTL_MS is 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.sync runs on the realtime lane 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 / warehouseReleaseNumber carry 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 only not-released is a claim: released is the ordinary success and carries its number, not-applicable means there was nothing to release, and null means 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.warehouseReleaseMissing is the pure derivation, and the FE badge resolves through it so a service-only document and an unasked provider are not accused identically.
  • Subiekt nexo (via the Sfera bridge), @openlinker/integrations-subiekt-nexo, adapterKey subiekt.nexo.v1, platformType subiekt-nexo, registry capability Invoicing — a SEPARATE InsERT product from Subiekt GT, reached through a different bridge built on the .NET InsERT.Moria.Sfera SDK. Invoicing only; it implements the same RegulatoryStatusReader, CorrectionIssuer, BankAccountsReader and BankAccountDefaultSetter sub-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, adapterKey eparagony.documents.v3, registry capability Invoicing — the SAME connection and the same adapter key that carries Fiscalization (§ 16). EparagonyInvoicingAdapter implements InvoicingPort + 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.issueCorrection reaches the vendor’s sibling eCorrectiveInvoice document. Three things follow from one connection carrying two document kinds. The two adapters share ONE EparagonyHttpClient — the client owns the OAuth token cache and the vendor rate-limits /auth/token per 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: getCapabilityAdapter builds 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. CorrectionIssuer is advertised together with the adapter code that delivers it (the #980 rule), since the vendor’s eCorrectiveInvoice document is now composed rather than refused pre-call. The invoice lane is opt-in on every path into the product — enabledCapabilities is stamped at create and never retro-filled (#2085), so no existing receipts connection gains it, and the guided wizard sends enabledCapabilities: ['Fiscalization'] explicitly rather than letting ConnectionService.create default 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 by composeInvoiceDocument refusing pre-call with an EparagonyConfigException (failureMode: 'rejected') before anything crosses the boundary. A cleared merchantAddress is an explicit null under the repo’s own cleared-knob convention (#2610), so both the validator and the mapper read null as absent — the two halves must agree, because a mapper that dereferenced it would throw a raw TypeError that core cannot classify and defaults to in-doubt, blocking that order from every sales document on every connection (#3274).
  • 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.
  • AnalyticsTrustService enumerates OrderSource-capable connections via listCapabilityAdapters({ 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 to needs_reauth, and the default active-only filter would silently drop exactly the connections this read exists to warn about. A connection whose own status isn’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 succeeded marketplace.orders.poll job) is a pipe-liveness signal, thresholded against a staleness window; lastOrderIngestedAt (last succeeded marketplace.order.sync job, 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, mirroring SchedulerService’s own enabledEnvVar/enabledDefault runtime 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); computeWorstStatus rolls the per-connection statuses up to one worstStatus for the FE banner, ranked fresh < never-ingested < stalled < disconnected < unknown.
  • Cross-context seam discipline: the new context does not inject SyncJobRepositoryPort or the concrete SchedulerTaskRegistryService directly — it consumes both through the existing published ISyncJobsService interface (extended with findLastSucceededJob and findEnabledPollTask), keeping the cross-context contract to I*Service per 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.earliestOrderDate is the real fact, MIN(COALESCE(placedAt, createdAt)) over the connection’s order_records, read through IOrderRecordService.getEarliestOrderDateByConnection — never OrderRecordRepositoryPort directly, mirroring the getFailedSyncValueSummary (#1983) cross-context precedent. AnalyticsTrustService.getIngestionTrustSnapshot calls 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) reports earliestOrderDate: null, distinct from a non-null value that merely predates the placedAt backfill and resolved through the createdAt fallback instead.
  • Interface: GET /analytics/trust (apps/api/src/analytics-trust/), guarded by the global JwtAuthGuard.

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 of InvoicingPort (different issuer, device dependency, legal basis, retry semantics). Code identifiers and the capability value use the -ization spelling, matching the repository’s identifier convention; prose, spec and issue titles keep -isation.
  • Capability: FiscalizationPort (registry capability Fiscalization), 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: a FiscalRegistrationLocator for 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 dispatched Fiscalization adapter 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 rejected re-crosses the boundary; an indeterminate outcome is surfaced, never blindly resent. No degraded/offline mode ships, deliberately: in-doubt is 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, adapterKey eparagony.documents.v3, platformType eparagony, registry capabilities Fiscalization and, since #3192, Invoicing - the vendor issues a receipt and an invoice through the same POST /documents endpoint under the same OAuth scopes, so one connection carries both lanes over one shared EparagonyHttpClient and an operator enables the ones they want (the invoice half is described under § 14 Invoicing). The two lanes are independently enabled: Invoicing reaching the manifest grants it to no EXISTING connection (enabledCapabilities is stamped at create and never retro-filled, #2085), and the guided wizard sends enabledCapabilities: ['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, so EparagonyFiscalizationAdapter implements FiscalizationPort + FiscalRegistrationLocator and no device sub-capability - print/fiscalize are booleans inside the vendor’s document payload, not device operations, so there was never a device operation for one to call. #1910 was closed not_planned on 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) registerTransaction blocks on a bounded status poll. The vendor’s create answers 202 Accepted before the device has registered anything and may still fail afterwards, so returning at 202 would report a completed registration that does not exist - and registered is 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 false rejected. (3) A locate answers from three outcomes, and one contract mismatch is still carried honestly rather than papered over. locateByQuery reports registered / held / not-found (ADR-042 amendment #2502, decision 1, #2520): a document the vendor holds at a non-terminal status answers held, which reconcile surfaces as still-unknown and which leaves the record byte-identical. still-unknown means 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 stamped detail: '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 only resolved writes - 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 raises FiscalReconcileCheckFailedException -> HTTP 502, because reporting a transient network failure as the structural unsupported would tell an operator to stop retrying something that is merely transient, and reporting it as not-found would assert an absence the provider never asserted, instead of the pre-#2520 null that reported normal in-provider processing to the operator as an absence. Only CONFIRMED may terminalise a record, a vendor-reported ERROR answers not-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 through readFiscalLocateAnswer, so an out-of-tree adapter still answering the pre-#2502 FiscalLocateResult | null shape keeps working. What remains carried is the vendor’s fiscalization-status webhook, deliberately not wired, because CanonicalInboundEvent.domain is 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.getInFlightRegistration and its invoicing twin IInvoiceService.getInFlightIssuance answer 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. The since it 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). FiscalArtefactSummary carries 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 from null, 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: disposition is 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.

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 invoicing nor fiscalization can 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 normal sales-documents.tokens.ts (the #2100-era exception to engineering-standards.md § Symbol DI Token Re-export Convention rule 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-priority and 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, beside evaluateSalesDocumentRules) 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 only multi-currency-rule is reachable through the shipped wiring today (SalesDocumentRuleRepository.toDomain filters a malformed persisted condition before the detector sees the row, and the DTO answers 400 for a malformed draft), so unreadable-condition is 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 why SalesDocumentCondition carries 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-check persists nothing and refuses nothing, and createRule still refuses only an identical conditionsHash - 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 explicit acknowledgeOverlap escape hatch - the acknowledgeAboveRecommended shape operational-settings uses - 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’s data alone, so one keystroke reset the verdict to undefined, emptied every array and re-enabled the save with no banner. Bounds are reduced per field as a conjunction (several gtes narrow to the largest), so a last-one-wins reduction would wrongly report a collision; gte is an inclusive lower bound and lt an exclusive upper one, which makes the shipped PL template’s own < 450 / >= 450 pair provably disjoint rather than a self-collision.
  • A rule’s amount lives ON the condition (#3189, reversing ADR-041 decision 5’s thresholdRef indirection): orderTotalGross carried a thresholdRef naming a row in sales_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 carries amount + currency directly. Five properties are load-bearing. (1) amount is a DECIMAL STRING, never a JSON number — conditions round-trip through jsonb, and a number loses precision there and re-serialises differently, which would change conditionsHash and therefore the unique index backing the duplicate guard. Routing compares with Number() over a value isDecimalAmountString has already made safe - order.totalGross is itself a JS number, so no comparison against it can be more exact than that operand. compareDecimalAmountStrings (BigInt, never parseFloat) 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. isSalesDocumentCondition returns false on 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 each thresholdRef against the version effective on the migration date, rewrites the condition inline, and recomputes conditionsHash with 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, and down() throws, because the pre-migration thresholdRef is not recoverable from the inlined amount once a threshold row has since been versioned. (4) sales_document_thresholds is 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’s no-tax-id rule is DELETED in the same change, because it could never match: buyerHasTaxId reads false only for a positively asserted has none, and readSourceBuyerTaxId structurally 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 a notPresent operator 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 the fiscal-receipt outcome rather than becoming a kind of its own) and SalesDocumentDecision (#2155, the resolver’s output — a (documentKind, connectionId) pair or unresolved). SalesDocumentUnresolvedReason (routing could not decide) and SalesDocumentGateBlockReason (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). Plus SalesDocumentBlock, the SalesDocumentBlockOutcome the gate reports (none / blocked / indeterminate), SalesDocumentAttentionReasonValues (the aggregate-worthy subset), and the two is* 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 by AutoIssueTriggerService BEFORE resolveSalesDocumentRouting (#2155, the operator-configured single-primary model), via ISalesDocumentRulesService.resolveRouting (SALES_DOCUMENT_RULES_SERVICE_TOKEN, @openlinker/core/sales-documents — InvoicingModule now imports SalesDocumentsModule). A small pure mapper (toSalesDocumentOrderFacts, libs/core/src/invoicing/application/mappers/) builds the engine’s SalesDocumentOrderFacts input from the order’s DELIVERY (shipping) address country + totals, with buyerHasTaxId read from the order’s own buyer tax id since #2599 (never defaulted to false - “unknown” must not collapse into “known to have no tax id”, which is why SalesDocumentOrderFacts.buyerHasTaxId was widened from boolean to boolean | undefined). Fallback precedence: any rule-engine decision OTHER than unresolved/'no-configuration-for-country' (a route, an aggregate, or a different unresolved reason) is used as-is and flows into the SAME dispatch/reporting pipeline as before — a non-no-configuration-for-country unresolved reason therefore surfaces via the existing SalesDocumentBlockOutcome seam, never a silent fallback. Only 'no-configuration-for-country' (or an order with no delivery-address country at all) falls through to resolveSalesDocumentRouting, which keeps an untouched install byte-identical to its pre-#2170 behaviour. The flagship Poland buyerHasTaxId condition 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: buyerHasTaxId is undefined for absent and false only for a positive “has none”, so evaluateSalesDocumentRules’s existing handling of an absent fact (an undefined compares unequal to both true and false) 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 a NIP rule in libs/core is exactly what that forbids. It is PII-gated like customerEmail: for a sole trader the tax id identifies a natural person, sanitizeAddress already 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.buyerTaxId is denormalized off the snapshot’s billing address (NULL = not asserted, '' = asserted-none, otherwise the id - so a bare IS NOT NULL reads the middle state wrong; use decodeBuyerTaxIdColumn), 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 shared readSourceBuyerTaxId coercer, 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 reads ps_address.vat_number on 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’s invoice.address.company + its ids[]/taxId fields; Erli’s user.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 of meta_data keys (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.buyerTaxId stores a bare number, so TaxIdentifier.scheme is OPTIONAL and core hands the value over with no tag - minting one would make libs/core name 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 absent scheme means “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 one nip) 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): SelfRoutingDocumentKind is 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 / TaxRateEraValues in tax-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 neither invoicing nor fiscalization could own it for the other. Pure coercion over process.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; minorUnitExponentFor is 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 by scripts/check-shipping-tax-split-mirror.mjs under pnpm 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 /orders row 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 resolved SalesDocumentKind, 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 carries InvoiceStatus and RegulatoryStatus, a fiscal receipt carries FiscalRegistrationStatus alone and terminalises at registered with no authority rung at all - so a flattened single enum was rejected, because it makes issued, then rejected by the authority (the state RegulatoryResubmitter exists for) impossible to display, and puts a meaningless cleared on every receipt. And a surface never re-derives a fact the backend persists: the reason an order has no document is read from order_records.salesDocumentBlockReason / salesDocumentUnresolvedReason (#2100), never inferred from the order - a design draft that inferred it printed no rule for PL over an entirely different persisted cause, which is a false statement about the operator’s own configuration. The list read is batched across order ids (the getEarliestOrderDateByConnection precedent, #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 at docs/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 invoicing and orders value-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.ts fails 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>/types cycle-breaker sub-barrel that re-exports no runtime value: Order from @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 a require()) — @nestjs/* / typeorm / node:* imports are unrestricted, since those are ordinary infrastructure dependencies every other context also has.

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 + totalAmount stay 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; StoredExchangeRate once it carries a registry id) and ReportingCurrencySetting (the singleton row). Tables: exchange_rates, keyed (source, from, to, rateDate), and reporting_currency_setting, keyed id = 'singleton' - the shape ai_provider_active_setting establishes.

  • 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 as EUR (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 no Connection.config.fx key and no per-connection FX configuration. Save-time validation is three layers and zero HTTP: ISO-4217 shape (400), membership of SUPPORTED_REPORTING_CURRENCIES narrowed 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_CURRENCY in rate-source-resolution.ts maps PLN -> nbp and EUR -> ecb. Each publisher quotes against exactly one base (NBP publishes X -> PLN, ECB publishes EUR -> 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.rate is the number of to units per one from unit. For a stamp, from is always the order’s own currency and to the reporting currency, so a stamp is always total × rate and 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) and derivation is NOT NULL even for a direct rate, so it is never a field a consumer has to guess about. rate is a string end to end against a numeric(18,8) column - Number()-ing it in toDomain, 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: ExchangeRateRepositoryPort declares exactly findByKey and insertIfAbsent - no update, no upsert, no delete, no id-carrying save. 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 domain DuplicateExchangeRateError -> re-select the winner), matching IdentifierMappingRepository.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.claimWaybillRelay shape: IsNull() in the WHERE, affected > 0 as the answer), never the persistOrder upsert path, so re-ingestion cannot move a reported figure. Before any rate lookup, the resolved reporting currency and rule are persisted as fxIntendedCurrency / 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 - the AiCompletionPort / @openlinker/integrations-ai split, and deliberately not conditioned on whether a source needs a credential today, so nothing moves packages if NBP or ECB adds a key. FxIntegrationModule registers NbpExchangeRateAdapter and EcbExchangeRateAdapter (plus a deterministic FakeExchangeRateAdapter for tests) into the core ExchangeRateProviderRegistryService at boot, exactly as integration modules already populate AdapterRegistryService (#570/#571), and appears in both apiPlugins and workerPlugins purely 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, no createCapabilityAdapter, and no getCapabilityAdapter path - and the connection-bound HTTP transport is structurally unusable here, since it keys its cache and its rate-limit bucket on connection.id and a rate read has no connection. One shipped detail diverges from what the ADR anticipated: ExchangeRate.sourceRef is a source-assigned document reference only for NBP (its table number, e.g. 149/A/NBP/2026). ECB assigns none - its header.id is 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. resolveRateDate derives a candidate calendar day from placedAt (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 as endPeriod with lastNObservations=1 and 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 future endPeriod makes ECB answer a months-stale rate at HTTP 200 with no signal of any kind. A missing or unparseable placedAt returns null, the terminal signal (no stamp, no retry): the WooCommerce order source sets createdAt but not placedAt, 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):

    StatereportingCurrencyreportingTotalAmountexchangeRateIdfxRulefxStampedAtfxIntendedCurrency
    Never attempted (pre-feature, or not yet reached)NULLNULLNULLNULLNULLNULL
    Attempted, deferred to the retry jobNULLNULLNULLsetNULLset
    Terminal (no placedAt, unsupported pair)NULLNULLNULLsetsetset
    Stamped, same currencysetsetNULLsetsetset
    Stamped, convertedsetsetsetsetsetset

    A consumer must read reportingCurrency IS NULL as “unstamped” and never exchangeRateId 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 NULL is 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 is reportingCurrency 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-source clears the moment a host is rewired with FxIntegrationModule, and an unsupported-pair raised 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 moves fxStampedAt forward so the row is retried once per cooldown rather than on every tick. reportingCurrency IS NULL is 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: getDailyOrderAggregates sums reportingTotalAmount only where reportingCurrency = :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.clearFxStampForRestatement is therefore the one writer allowed to move a stamp: it nulls the same six columns the 1840000000000-reset-fx-stamp-for-mislabelled-prestashop-orders migration established, in one guarded statement, and is reachable only from OrderFxRestatementService inside an analytics_remediation_runs audit 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. fxIntendedCurrency must be cleared with the rest - resolveIntent re-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 calls IOrderFxStampService.stamp directly, sequentially, in-process inside the already-bulk-laned analytics.currency.recalculate job — NO CHILD JOB, and therefore no separate idempotency-key space to worry about (OrderFxStampService.enqueueRetry’s bare fx:{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: stampOne guards each stamp() call the same way clearOne guards the clear, so a caught failure counts as failed and 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 writes reportingTotalAmount = totals.total with no lookup and no I/O, reportingCurrency - not exchangeRateId - 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 implementing KursWaluty will 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 on order.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, while KursUmowny / 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 by IExchangeRateLookupService.findRate, a one-method findByKey passthrough deliberately kept separate from ICurrencyRateService so the endpoint structurally cannot reach the get-or-create getRateFor and 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 merely JwtAuthGuard-gated: since #2413 RolesGuard denies an undecorated route, so every route names its audience explicitly - see engineering-standards.md § Route authorization.) The same provenance is threaded onto the display-currency conversion result: AppliedRate {from, to, rate, rateDate, source, derivation, sourceRef} on NativeCurrencyBreakdown / OrderDateConversionResult, null exactly when no lookup happened (an unresolved row, or a same-currency identity that never called the provider), surfaced as appliedRates[] on DisplayCurrencyConversionDto. rate stays a string end to end on both surfaces, matching the numeric(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): string is optional (ADR-046 probe-not-trust: CurrencyRateService checks typeof 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-fetch findByKey READ uses: fetchRate still 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 to fetchRate unchanged), 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.

  • Responsibility: A read-only, per-connection catalog-replication trust signal (#2258, the operator-facing half of ADR-048 decision 2) — which capability rung a ProductMaster connection’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 — the analytics-trust shape (#1982): a composition of IIntegrationsService, ISyncCursorsService and ISyncJobsService.
  • Location: libs/core/src/catalog-trust/. Interface: GET /connections/:connectionId/catalog-trust (apps/api/src/catalog-trust/, nested route prefix — the bare connections prefix is owned by the integrations module); rendered as a panel on the connection detail page’s health tab, gated on the connection’s enabled ProductMaster capability.
  • The rung is guard-narrowed, never manifest-derived. ModifiedProductLister is guard-only (#2220 — absent from every manifest and from CoreCapabilityValues), so the service resolves the dispatched ProductMaster adapter and narrows with isModifiedProductLister. listCapabilityAdapters({ lazy: true, includeAllStatuses: true }) answers only membership — in lazy mode the entry’s adapter is the memoized construction Promise, so narrowing it directly would silently classify every rung 'full-enumeration'; the rung read goes through getCapabilityAdapter. 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: true log-only). The reconcile handler now additionally stamps master.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/sync as the single source; the worker’s bounded-sweep.ts re-exports it under the original names.
  • Honesty rules the vocabulary: reconcileCycleOpen means 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 spans ceil(N / budget) ticks); and deltaPassEnabled (via the new ISyncJobsService.findEnabledTaskByJobType, which serves capability-scoped tasks that findEnabledPollTask’s platformType filter can never match) closes the “declared but dormant” misreading — a modified-since rung 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.

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-documents already 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 in fulfillment, A4 in the lifecycle projection, A5 in returns, A6 in orders), 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 single oms-policy context resolving everything was rejected for exactly that graph reason. Enforced per leaf by libs/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 ordinary fulfillment-authority.tokens.ts the day it needs a binding, exactly as sales-documents did 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 behind Needs attention (N) and every cross-surface row badge, with AUTHORITY_ATTENTION_REASON_DESCRIPTORS as the single data table §4.3 mandates (“one table, two readers”) and AuthorityAttentionCountedReasonValues derived from its counted flag rather than hand-listed, the SalesDocumentAttentionReasonValues shape. 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 of FulfillmentAuthorityBlock, never parallel spellings — attentionReasonForAuthorityBlock is the primitive and attentionReasonForAuthorityQuestion a 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 what fulfillment-authority-outcome.types.ts warns against. (3) A derived state is never persisted. origin records how each is obtained: A1-U/A2-A/A5-A are pure functions of Connection.config recomputed 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 to connections at all, and a stored copy would be a second answer with a staleness window (and ConnectionRepository.update is a read-modify-write full-row save(), 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 (getCapabilityAdapter is active-only — hence the leaf’s existing holder-connection-unresolvable), is not expressible in config, and belongs to inventory’s own enforcement resolution. Stated so a consumer cannot read availability-unknown as “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 jsonb array per owning row, keyed by PRODUCER (#2352) — order_records.omsAttention and returns.omsAttention, both with the #2100 discipline (excluded from every toOrm, a single narrow atomic writer, coerced through a guard on read so an unrecognised value reads as absent, an explicit counted IN-list rather than IS NOT NULL). The array is the one deliberate divergence from #2100’s scalar, and it is a correctness requirement. salesDocumentBlockReason is safe as a scalar because ONE authority re-decides the whole question on every order transition, so its null is 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’s since across a change of reason inside one episode so an operator-facing age is not reset by a refinement. indeterminate leaves 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 on IDX_order_records_salesDocumentBlockReason when #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/preview and PUT /fulfillment-authority/presets live in apps/api/src/fulfillment-authority/, composed by an app-layer AuthorityStatusService over IConnectionService + IIntegrationsService + the order-record port — the RateLimitStatusService / WebhookStatusService shape, except that those inject ConnectionPort directly (which they may, living inside the module that provides it) while this one reads AND writes through IConnectionService, 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 are config-only, so any connection may claim them, and isActive is REPORTED rather than filtered upstream: an active-only read is the analytics-trust trap, and here it would hide exactly the disabled connection whose lingering claim an operator is trying to understand. supportedCapabilities comes from resolveAdapterMetadata, a metadata-only lookup that constructs no adapter and resolves no credentials and therefore works on a disabled connection where getAdapter throws; a resolution failure degrades that connection to advertising nothing rather than dropping it, since a config-only claim 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 via attentionReasonForAuthorityQuestion (never stored, per the rule above) and carries the offending candidateConnectionIds; the persisted half is countOrdersWithOmsAttention(). 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. The routine array 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 an AuthorityState/AuthoritySource/AuthorityAnswer and 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 because resolveAuthorities takes 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: resolveAuthorities may 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 back ambiguous, 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_PRESETS is the single place a preset’s semantics live. leave-as-they-are returns the very same config reference (card 1: “Nothing changes when you pick this”), so no connection is written and no updatedAt bumped; openlinker-decides sets enabled: false on every assignable claim and preserves the assignment — the connection, its scopes, its isPrimary — 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-system is available: false with 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 is fixed-by-design and its inactiveClaimantConnectionIds is 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 through IConnectionService, never ConnectionPort — the service is where per-plugin config-shape validation, adapterKey immutability and the taxonomy bootstrap live. Every field on the wire is a CODE; operator copy is the frontend’s and must pass check-ui-vocabulary, which a backend string could never enter.
  • The operator surface (#2354) — /settings/who-decides (apps/web/src/features/fulfillment-authority/, reached from a SettingsPage tile) 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 what useWriteAccess('connections:write') gates, and settings-page.test.tsx pins 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 reads state and source, never the question — A6’s lock and A7’s link-out are core’s statements (deriveAuthorityState, AuthoritySource), so a question === 'refund-trigger' test in the browser would be a second copy of a rule that lives in the leaf; the state arm is an EXHAUSTIVE switch with a never default rather than a fall-through, because an otherwise -> Chosen arm is total only by an invariant apps/web can neither import (#591) nor observe, and would render Chosen on 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’s question against 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.failedConnectionIds non-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-decides switches every assignable claim off, which on a fresh install is indistinguishable from nobody having configured anything. The row is a non-DataTable CSS-grid list registered in docs/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 the Needs attention section plus its cross-surface badges are #2356; this page renders neither.
  • The ?attention= orders axis (#2353) — OrderRecordFilters.omsAttention plus an omsAttention count folded into the orders summary aggregate, both reading the one private HAS_OMS_ATTENTION predicate. It is not the needs_attention HEALTH 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 with health exactly like salesDocumentBlocked and taxRateConflict, never folded into it. countOrdersWithOmsAttention() is retained alongside the fold rather than superseded by it: the summary is filter-scoped so the /orders chip 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.

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 OrderLifecyclePhase and its precedence order, the merged HoldReason union, OrderAmendmentKind, LifecycleAuthority with its pure coercer, the internal-only OmsLifecycleFact union, the one-way phaseToOrderStatus projection, and deriveOrderLifecyclePhase (#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/types cycle-breaker sub-barrel — the same shape sales-documents holds for Order (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 is HoldReason, because the union is used at both the order and fulfilment-work grains. Do not “correct” it back.

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 inside orders — orders is 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 constraint CHK_return_lines_quantity_ordering rather 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 no inspected — collapsed into received because nothing writes it and no surface distinguishes the two; the reversal gate is a ReturnReceiver/3PL flow where the receiving and inspecting parties genuinely differ (ADR-060 amended accordingly by #2327). Money carries in_doubt because OL ships no refund WRITE: an execution OL cannot observe must be recordable as unknown rather than reported as refunded. Disposition is restock | scrap only — 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 in domain/domain-services/return-custody-transitions.domain-service.ts — advanceReturnCustodyToInTransit / applyReturnCustodyReceipt / applyReturnCustodyDisposition / markReturnCustodyNotReturned, plus isReturnCustodyFinished (the custody half of the list’s All open segment). 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 / disposedAt and the counters the move implies; they persist nothing — #2370 (W2-33) owns the write, restock_blocked and the master adjustInventory call. Transitions are per line and quantity-aware, which is the whole reason custody lives on the line: a return received in two parcels legitimately holds received on one line and disposed on another, and received is 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_transit takes 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 fixed declinedAt under, 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_returned is always an operator act and never a timeout or a sweep (spec § 5.2) — which also settles the question ReturnRepository.warnOnVanishedLines left open: a line the source stops reporting is not auto-transitioned, because a source withdrawing a line says nothing about physical custody. And not_returned is 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 no quantityNotReturned counter for the shortfall to move into — such a line finishes on the received branch with the shortfall permanently visible as quantityAdvised - 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 with assertNever, which is what gives spec § 3.1’s inspected reversal 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): IReturnCustodyService gives the aggregate its two operator write paths — receiveLine and disposeLine — plus markStockHandledManually, and is the tree’s first production caller of InventoryMasterPort.adjustInventory. Alongside them lands an append-only per-line act ledger (return_line_events, migration 1852000000000), 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 fire return.received three times, and quantityReceived going 1 -> 2 -> 3 carries no per-arrival identity and is indistinguishable from a correction; it is also what finally gives #2368’s return:{returnId}:{lineId}:{seq} key a seq to name, and what lets restock_blocked be 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 the CHK_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 increment quantityRestocked. 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 in quantityReceived until an operator attests (spec § 5.4), which is what lets #2381 assert that no surface renders blocked units as restocked. applyReturnCustodyDisposition is 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 leaves in_doubt rather than silence; in_doubt never 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-line SyncLockPort lock (return:line:{lineId}) and every counter write takes SELECT … FOR UPDATE — two different guards for two different failures. The lock covers read-then-act across the provider boundary (the invoiceIssueLockKey shape, ADR-041 §3a: two concurrent disposals otherwise both pass the check and both apply under different seq values, so no adapter can dedupe them); the row lock covers the lost update the CHECK is silent on, since receiving 2 twice against advised: 5 and 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 through IProductsService — a return line carries no product id and cannot (resolvedOrderLineId is a by-value reference into the order snapshot’s jsonb, written only at ingestion since #3171 and legitimately null for 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 outstanding blocked/in_doubt act, records restockedBy: '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 ordinary inventory.propagateToMarketplaces fan-out carries the master adjustment onward, asserted by a spec. Known limitation, named rather than approximated: not_returned on 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 as quantityAdvised - quantityReceived.

  • A restock goes back into the master that OWNS the line (#3486, epic #3460): #2370 resolved ONE global InventoryMaster and 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. ReturnCustodyService now builds a per-line restock plan. With exactly one InventoryMaster connection 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 through IInventoryQueryService.resolveStockOwner, which applies the SAME pure rule the routed-order sale decrement (#3453) acts on - resolveInventoryPositionOwner in inventory-owner.types.ts, extracted from resolveSaleDecrementOwner so 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) and ambiguous-owner (a product held in both masters) - and an owner that is not among the active InventoryMaster connections blocks as adapter-unresolved, never falling through to the other master. Known gap: the return-detail read getRestockTarget() is still return-level and answers ambiguous-inventory-master with 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.internalOrderId is 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 index IDX_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.resolvedOrderLineId is nullable for a stronger reason still — order_records has no lines table (items live inside the orderSnapshot jsonb), 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; the refund_records / invoice_records precedent (indexed reference by value, no cross-table lock coupling) governs. The ONE foreign key in the schema is return_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. rawPayload is a known PII gap, landed and named rather than silently carried: it has no OL_STORE_PII parity with customer_projections, which is an INGESTION concern (#2330) and cannot be decided against a payload shape that does not exist yet.

  • RefundRecord is LINKED, not extended (#2327): refund_records gains a nullable returnId (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 widening RefundRecord would 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.reason reuses RefundReason verbatim (via the @openlinker/core/orders/types cycle-breaker sub-barrel, so no OrdersModule edge 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 from ZERO_SIBLING_EDGE_LEAVES in barrel-purity.spec.ts. orders must never import returns back — a return-shaped read added to an orders service would close a CJS module-load cycle; it belongs on this context’s own IReturnsService (#2328), which is also why a sibling reaches the aggregate through that interface and never through RETURN_REPOSITORY_TOKEN. It is likewise not re-exported from the aggregating root barrel (the sales-documents posture), and is reached at its own @openlinker/core/returns subpath.

  • Ids are ol_return_* via formatInternalId('Return') with ZERO registration — no ENTITY_TYPE_ID_PREFIX override and no CoreEntityTypeValues member, because that union is the external-mapping vocabulary and a return is not mapped through identifier_mappings: externalReturnId is a column on the row, resolved by the partial unique index UQ_returns_source_external (the (sourceConnectionId, externalReturnId) update-or-create key of DESIGN-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_records precedent).

  • Ingestion is an idempotent update-or-create, and its key must exist (#2328): ReturnsService.upsertFromObservation maps the neutral IncomingReturn projection onto the aggregate and writes it through ReturnRepositoryPort.upsertFromSource — header and lines in ONE transaction, one INSERT … ON CONFLICT DO UPDATE per 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; the product_content_field precedent), 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-retryable ReturnObservationMissingExternalIdError and 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 an operator_authored return, which is created through create() and never ingested. Attribution is a lookup and it is monotonic: getInternalId, never getOrCreateInternalId (which would mint an internal id for an order OL has never ingested and point every downstream trigger at a phantom), and internalOrderId + openedAt are applied with COALESCE so 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 / closedAt and every Wave-2 line column (the counters beyond quantityAdvised, 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 as null whatever the row holds, and a caller needing their true value re-reads via findById. 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 no created flag: ON CONFLICT … DO UPDATE always produces a row, distinguishing insert from update would need the MVCC internal xmax = 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.resolvedOrderLineId holds the orderSnapshot.items[].id that reservations.orderLineId, shipment_lines.lineId and FulfillmentWorkLine.orderLineId already 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 an ambiguous line with a two-option picker. That question is both unanswerable and manufactured: the operator holds a parcel while InvoiceLine carries no id and no SKU, and OpenLinker builds the invoice itself from order.items in order (order-to-issue-invoice-command.mapper.ts:113), so invoice line N is order.items[N-1] deterministically while the return line arrives carrying sku and unitPrice. Four properties are load-bearing. (1) The rule REFUSES rather than guesses — NrWierszaFa is mandatory in every permitted FA(3) correction method, so a guessed line is an unauditable entry in a filed document. resolveReturnLineOrderLine narrows 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 own offerId when no SKU was reported — Allegro’s return payload carries no SKU at all, only offerId, while its order-line mapper writes sku: lineItem.offer.id, so on the only shipped ReturnSourceReader the two sides report ONE identifier under two field names and offerId is the axis that actually resolves a line, not sku. Either identity axis breaks a same-identity tie on the reported unit price; more than one survivor is ambiguous, 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, the claimAttribution idiom), so a later or failed re-resolve can never un-resolve a line, and the column stays absent from BOTH halves of upsertFromSource — it is core-resolved and must never be echoed by a source, which libs/core/src/returns/__tests__/order-line-resolution-is-core-owned.spec.ts pins. (4) The ORDER READ lives in the worker, not in returns. The rule and the write are core’s; only the cross-context read is composed by ReturnOrderLineResolverService (apps/worker/src/sync/), which reads OrderRecord.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”). returns therefore takes no new module edge: OrdersModule imports fourteen modules, nine of them new to returns, for one getOrderRecord call, and the repository has twice preferred the worker for exactly this shape (OrderHoldsModule exists as a leaf “so OrderHoldService … 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: an ambiguous or no-candidate outcome is reported only in ReturnOrderLineResolutionSummary and logged at warn — 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; the marketplace.returns.statusSync lifecycle re-read does not resolve, because ReturnStatusSyncResult reports 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 — narrowing return-correction-matching.domain-service.ts onto 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 CustomerReturn carries createdAt and no updatedAt, and /order/events has no return event type at all (SPIKE-2289 E7/E8), so a cursor defined over creation can never observe a return moving CREATED -> 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 out marketplace.return.sync (realtime, hydrate + idempotent upsert), while marketplace.returns.statusSync (bulk) re-reads OL’s own non-terminal returns inline — the master.product.syncAll / master.product.reconcile split 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): ReturnIngestionService mirrors OrderIngestionService — per-connection lock, deterministic marketplace:{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. The isCursorRegression guard is deliberately NOT ported. A return cursor is a UUID, so Number() yields NaN, 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. ISyncCursorsService states 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 full ReturnSourceReader) — applied by core as an OPAQUE NOT IN over the stored rawStatus with 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 on connection_cursors (the marketplace.offer.statusSync #816 shape, and the handler owns the offset via ConnectionCursorRepositoryPort exactly as that sibling does, not via ISyncCursorsService). 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 as terminalVocabularyDeclared: false rather than left looking like a defect — and the empty list is OMITTED from the SQL, since NOT 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 terminal rawStatus drops the row from the next candidate set by itself).

  • Allegro is the first implementer, and its two scheduler tasks ship OFF (#2330): AllegroOrderSourceAdapter gains listReturnFeed / getReturn over the [BETA] customer-returns resource (media type set PER REQUEST via the caller-header hook, since every other Allegro call wants public.v1 and a client-wide default would silently retag them all), with the wire types and the single terminal constant in allegro-customer-return.types.ts and the projection extracted to allegro-customer-return.mapper.ts (the allegro-payment-status.ts precedent — the adapter was already 780 lines). ONE constant feeds both terminalRawStatuses and the per-observation isTerminalAtSource hint, spec-asserted, because deriving them independently is exactly the drift that produces a return excluded from the sweep while still reported as open. It sends from and never offset (deep offsets 504), terminates on an empty array and never on count (whose semantics are unstated), never composes from with a filter (the composition order is undocumented — risk 1 is designed around, not guessed at), and bootstraps a createdAt.gte window 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[].price is Allegro’s Price with a string amount (verified against the spec, which the spike sketch left untyped), parsed to a number with an unparseable value reported as absent rather than NaN. reason.type rides through verbatim and userComment is deliberately NOT merged into it — buyer prose would defeat core’s reason mapping for exactly the returns whose buyer bothered to explain themselves — while refund / parcels / rejection are projected onto no neutral field and survive in raw. ReturnSourceReader joins the manifest as advertised-without-dispatch while both scheduler tasks require OrderSource: 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 marked needs-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): internalOrderId is 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 over internalOrderId === 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 named ReturnNotAttributedError carrying 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 an upsertFromSource result 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; ReturnNotFoundError is 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 in resolveInternalOrderId and discarded — it was not a column and not in rawPayload — so returns.externalOrderId is added (migration 1850000000007, plus a partial IDX_returns_orphan_reattribution) and applied with COALESCE like openedAt, never latest-wins like rawStatus: 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 (bulk lane, ReturnReattributionService, capability-scoped to OrderSource, */30, and default ON unlike the two #2330 ingestion tasks because it contacts no marketplace at all — one identifier_mappings read and one local UPDATE) enumerates OL’s OWN orphans and asks whether the order has since arrived. The write is claimAttribution, a conditional UPDATE WHERE "internalOrderId" IS NULL (the claimWaybillRelay shape) that is both the race seam against a concurrent upsertFromSource and 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 of findForSourceSweep’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 is alreadyAttributed, never unresolved (which would state that a return is still orphaned when a peer just attributed it) and never failed (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’s SyncJobExecutionError, because catching it per candidate would launder a deleted connection into failed: N on every page, every tick, forever, with nothing above warn. 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. decline is a member of the trigger vocabulary for a reason that is easy to misread as a category error: #2333’s return.decline is 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 NULL internalOrderId). ReturnDeclineService therefore asserts attribution through assertAttributedForTrigger('decline') rather than its own internalOrderId === null check. #2332 and #2333 were built concurrently and each defined a rival ReturnNotFoundError and ReturnNotAttributedError; there is now exactly ONE definition of each (the per-file #2332 pair), because two same-named classes fail instanceof against 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.authorize is restricted to origin: '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 a source_ingested return 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-044 order_changes table (#2333) with a second kind, never a second proposal mechanism (asserted by libs/core/src/returns/__tests__/no-second-proposal-mechanism.spec.ts, which also bans a returns-side import of OrderChangeRepositoryPort — 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. authorizedAt therefore 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 (the ReturnLineEvent.occurredAt side, opposite claimDeclinedAt). 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 between openOrReuse and confirm wedge the return behind a full TTL for no safety gain — the at-most-once guarantee is claimAuthorizedAt’s single conditional IS NULL UPDATE, exactly as claimDeclinedAt is. And there is no guard on declinedAt / 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' joins ReturnDownstreamTriggerValues so the orphan refusal comes from the ONE seam and raises the same class every other trigger does.

    matchOrphanToOrder is the operator’s way out of the #2332 bucket, beside the background reconcile, and unblocks every downstream trigger by construction — assertAttributedForTrigger re-reads the row, so filling internalOrderId is the whole of what a refund, a restock or a correction needs and no consumer changes. The order’s existence is proved through identifier_mappings, not through orders: 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 no OrdersModule edge, which ReturnsModule’s docblock warns against manufacturing. It proves an id was MINTED, not that an order_records row still stands, the same by-value posture internalOrderId already 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 own sourceConnectionId would refuse precisely the case the action exists for. The write is claimAttribution, 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, migration 1860000000000) rather than in return_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 by ReturnOrderLineResolverService, whose only caller is the marketplace.return.sync per-return path, which matchOrphanToOrder does not invoke (nor does the marketplace.returns.statusSync lifecycle 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.

    recordReturn opens a return in OL against an order OL already knows, for a source with no returns surface — the first production caller of ReturnRepositoryPort.create, which has had none since #2327. It writes origin: 'operator_authored' and externalReturnId: null, never synthesised: UQ_returns_source_external is 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). sourceConnectionId is REQUIRED and VALIDATED rather than derived — the column is uuid NOT NULL so the row must name a channel — and the mapping on that connection is what supplies externalOrderId, so the value is a fact OL already holds rather than an operator-typed string. openedAt takes OL’s clock; authorizedAt stays NULL, because recording and authorizing are two acts, which is the entire premise of “authorization is an ACTION, not a state” and what leaves return.authorize a 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_STATES deliberately does NOT narrow here. #2371 records that the set contains not_refundable only because it is #2327’s column default, and that a slice setting pending at creation narrows it to pending | denied with no call-site change. This slice declines that step: narrowing is sound only once EVERY writer sets pending, and source-ingested lines — every existing row in every install — would still carry not_refundable, making them permanently unrefundable, a silent money defect discovered one refund at a time. Setting pending for operator-authored lines only buys nothing (isRefundAttemptable treats 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 writing pending on 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 — ReturnCorrectionProposalService reads, classifies and records; it resolves no adapter, references neither issueCorrection nor CorrectionIssuer (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.originalLineNumber is a 1-based ARRAY POSITION, InvoiceLine carries no identifier and no sku, and ReturnLine carries no price — so name is 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. ambiguous is therefore a first-class outcome that lists EVERY candidate and selects none — never collapsed into matched even 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. candidatesPriceOrRateDiffer reports that coincidence as evidence for the operator instead. Five properties are decisions. (1) The targetRef is correction:{returnId}:{invoiceRecordId}, because UQ_order_changes_open_target is (internalOrderId, targetRef) and carries no kind: the bare returnId namespace is taken by return.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 reached quantityRestocked, contributes nothing; that line is excluded LOUDLY as no-match / disposition-not-confirmed (read once per build from findOutstandingRestockEventsForReturn) 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 latest issued record, which makes #1297’s correction-of-a-correction rule true by construction rather than by a special case, since a successful correction is itself an InvoiceRecord. (5) Every non-proposing exit is a named outcome (no-invoice / no-line-snapshot / no-disposed-lines / nothing-correctable), and nothing-correctable still 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 is abandoned and replaced (comparison is over a key-sorted canonical form, because Postgres normalises jsonb key order and a plain JSON.stringify would 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, unlike DestinationCategory.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.kind is varchar with no enum and no CHECK. The HTTP surface is #2376 and the review UI #2382. The matcher is now id-first (#3312). InvoiceLine.orderLineId carries OrderItem.id (set by toInvoiceLine, never by toShippingLines — no single order item backs a delivery line, which stays #3290’s concern) and ReturnLine.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 the orderLineId field. 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 reports no-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 new ReturnWritesController — the three per-line custody acts, authorize, match-order, the operator-authored record, the refund confirmation, and a GET/POST correction-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, carrying quantity, sku and connectionName verbatim: 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 actionable reason — the closed ReturnCustodyTransitionError.reason is emitted as a FIELD, exactly as ReturnNotAttributedError.trigger already is, because a client that parsed the message would break on the first reword. non-positive-quantity is 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) ReturnsExceptionFilter grew 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 SPLITS ReturnMatchRefusedError by reason: already-attributed is the return’s own state (409), while unknown-order means 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. ReturnRefundService reports an intent (#2371’s report-don’t-persist seam, which is what keeps OrdersModule out of core’s ReturnsModule); the controller writes the linked RefundRecord through IOrderRefundService under 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 with refundRecordWritten: false rather than a 5xx: the money state already settled durably, reporting failure would send the operator to retry, and the retry would answer 409 already-attempted. moneyMoved and refundRecordWritten are separate booleans precisely so “nothing to write” and “money moved, record pending” are not both a null id. (5) GET /returns/:id/correction-proposal cannot write, so #2374’s service gained previewProposal: both entry points delegate to one private compute, which makes “the preview and the recorded proposal agree” structural and “the GET persists nothing” true by construction. Binding the GET to buildProposal would have opened, abandoned and replaced ADR-044 rows on every page load. POST /returns/:id/commission-refund/claim is 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 no stage column exists on returns or return_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 SQL CASE is BUILT by iterating ReturnStageValues, so a reorder changes behaviour and scripts/check-return-stage-mirror.mjs treats one as a hard failure (the #2311 OrderLifecyclePhaseValues rule). 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_received outranks disposed: a return with 2 of 5 units arrived and both disposed is not finished, and calling it Disposed would say it is closed when three units may still turn up. (3) advised in every arm means STILL EXPECTED, not originally announced. markReturnCustodyNotReturned refuses a partially-received line, so a not_returned line always carries received = 0 while its quantityAdvised would otherwise sit in the denominator forever — a two-line return with one line disposed and one written off would render Partially received permanently, a false statement about work the operator has finished, which the not_returned arm cannot rescue because that arm needs EVERY line written off. notReturnedQuantityAdvised is subtracted; the semantic shift is the rule, not a patch on the arithmetic. (4) The FE derives it; the API does NOT ship a stage field. The row already holds the counters, so asking the server for a string it can compute is a round trip for nothing — and a stage on 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 leaves ReturnRecord.counters null and folds its real lines instead, and null means “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 a ReturnStageValues.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 three FALSE placeholder 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 SQL CASE over aggregate columns admit no textual equality. Meaning is proved by RETURN_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 have bucket removed and stage applied; stage counts have stage removed and bucket applied — and countReturnsByStage strips stage itself, so the rule survives a caller that forgets it. resolveScopedTotal deliberately gains no stage arm: the bucket counts already carry the stage filter, so it resolves the stage-filtered total unchanged, whereas reading stageCounts there 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 only Declined, 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; ReturnSegmentCounts is the opposite shape — one return can be needs_disposition and money_pending and orphans at once, and all_open deliberately 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_PREDICATES is therefore independent booleans, never a CASE ladder. (2) ONE segment URL 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 receiving spans awaiting_parcel or partially_received, Needs disposition spans received_awaiting_disposition or a partially-received line with undisposed units, and Money pending means pending or in_doubt, while stage and money are each single-valued. attention and orphan are dropped as separate params — they ARE segments, and two spellings of one filter is drift by construction. (3) orphans is an ordinary segment predicate, NOT a translation of bucket=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 send bucket, leaving no segment to strip, so the orphans card would report the count of the scope it is already in. It reaches the SAME rule ReturnRecord.isOrphan() states through one shared ORPHAN_PREDICATE SQL constant (consumed by the bucket count, the bucket filter arm and the segment alike), and bucket stays an independently-usable filter a segment click never writes. (4) The counts-scoping rule generalises: bucket counts have bucket removed and every other filter applied; stage counts have stage removed; segment counts have segment removed — and countReturnsBySegment strips 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 returns clears segment and is the default (unfiltered), the six segments sit beside it with All open among them. All open is a FILTER, not a clear — rendering it as the clear card would label closed, fully-refunded returns as open work. Only restock_blocked and orphans may render red (#2100’s split); all_open is neutral precisely because it is the largest card on any active install, and tones are keyed off MetricCard’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-1c tracked prop was built as this seam and flips to default-true, gaining rail labels, the in_doubt warning with its do not refund again copy (OL crossed a boundary and does not know the outcome; a retry moves real money twice) and the refunded attribution. refunded never renders without an observation — triggerRefund writes triggered and only recordRefundObservation writes refunded, so moneyState === 'refunded' IS the observation and nothing derives one from the other, pinned by a triggered-only fixture. The standing “these move independently” sentence ships as COPY, once, above the table. openedFrom/openedTo filter openedAt, the SOURCE’s own instant — never createdAt, 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 a RESTOCK_BLOCKED_EXISTS whose predicate is findOutstandingRestockEventsForReturn’s verbatim — attestation FLIPS a blocked act’s restockState to handled_manually rather than stamping it, so an attestedByEventId IS NULL clause would be backwards and keep every settled block forever. joinCountersOnce makes that join idempotent, because four paths can need it on one builder and a second sc alias 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. markReturnCustodyNotReturned was 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 a restockBlocked response — 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 over enabledCapabilities could not reproduce listCapabilityAdapters’ 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 — and ambiguous-inventory-master is 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 no quantityNotReturned counter. The control is gated on quantityReceived === 0 so the refusal is never discovered by clicking; where units did arrive the shortfall stays visible as quantityAdvised - 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-received sat after the state check, but receiving units is exactly what moves a line to received — 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 in docs/lessons.md. (5) A new ReturnLineEventKind member breaks nothing AND warns nobody — the union has no assertNever switch, no exhaustive Record, and reaches persistence through an unchecked cast, so consumers are found by grep. toLineEventKind converts that silence into a warning while passing the stored value through verbatim (it is what the row says, not a lie); unlike the readReturnReason precedent beside it, kind has no honest default and ReturnLineEvent.kind is 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 on kind; the first switch or Record keyed 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: a nothing-advised refusal so a zero-advised line answers 409 with a code rather than a raw CHK_return_line_events_quantity_positive 500; SegmentedControl gains real disabled support (arrow-nav skips it, the tab stop never parks on it) so Restock can 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.internalOrderId is an ordinary filter arm — deliberately NOT a translation of bucket: 'attributed', which is the #2378 orphans conflation one dimension over; it rides in the controller’s shared scope, so all four count reads see it and the panel’s numbers describe the rows beneath them. findTimelineEntriesForReturn is the SAME query and the SAME projection as #2383’s order-scoped read, differing only in the WHERE — one shared projectTimelineRows, 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 which closed is declared and written by nothing today (create takes it and its one production caller passes null; upsertFromSource blanks 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 no internalOrderId at 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 FE resolveBy keys 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 attribution REFUND_EXECUTED_BY_COPY exists to prevent. matched is the only such kind today; opened and declined still carry actorUserId: null and remain a source claim or nothing. (3) An empty projection is a 404, never an empty timeline — the join is a LEFT JOIN off returns, 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/events is @Roles('admin','operator','viewer'), for the reason #2905 narrowed its order-scoped sibling: the entries carry a refund amount and currency, and a packer can obtain return ids from the @AnyRole() list. That does NOT close the wider hole — GET /returns/:returnId is still @AnyRole() and already returns refunds[] with amounts — which is pre-existing and stated rather than implied fixed. (5) The panel lives in features/returns, not features/orders, a deliberate deviation from #2640’s text: features/returns already value-imports the orders barrel (return-money-panel.tsx), so a panel in features/orders re-exported from that barrel would close a runtime barrel-to-barrel cycle (#337/#359); pages/orders/order-detail-page.tsx already 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 rendering ReturnStageCell — 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.restockBlocked is 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 for ReturnNotAttributedError (#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 overlapped createdAt.gte repair re-sweep for SPIKE-2289 risk 2 (both named follow-ups). shipments.direction was likewise out of that slice and is now actioned — see § 23 below.

  • 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_shipments table would have duplicated every read predicate, the status machine and the waybillRelayedAt claim discipline. shipments.direction (outbound | return) is the column that lets one table hold both.
  • direction went into the branch-1 index’s KEY columns, never its WHERE clause. UQ_shipments_branch_one_per_order_conn is now UNIQUE (orderId, connectionId, direction) WHERE "providerShipmentId" IS NULL. Written instead as direction = '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 on Key ("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 1862000000000 adds it NOT NULL DEFAULT 'outbound' and drops the default in the same up(). 'outbound' is true of every existing row: shipments rows are written only by ShipmentDispatchService (an operator buying a label for goods going to a buyer) or by FulfillmentStatusSyncService’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 and direction would be unstated-but-present. The ORM entity declares no default either, so the migration and synchronize (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 / findBranchOneByOrderAndConnection require 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. findMany instead gains an optional ShipmentFilters.direction, because it also backs the operator-facing GET /shipments list, 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 exposes direction as both a filter and a response field so the operator sees the cohort instead of having it chosen for them. findBranchOneByOrderAndConnection additionally 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 own down().

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; like returns, 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 — HoldReason from @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_rules engine rather than inventing a second answer to “how is a rule stored and evaluated”: scope in real columns, kind-specific data in jsonb, 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 the AutomationRule entity 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); orderTotalGross carries an inline amount + currency rather than #2161’s thresholdRef (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 combined definitionHash over (trigger, triggerConfig, conditions, actions) because the action list is part of rule identity, and — like #2170 — no priority column, 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-document and A2 dispatch-shipment the #2047 rule applies verbatim — two matching rules resolve blocked, nothing fires, and the run row names both colliding rules via automation_runs.blockedByRuleIds (a single ruleId cannot, and spec §5.6 requires the row to say which rules collided). AUTOMATION_ACTION_IS_IRREVERSIBLE declares 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_TOKEN now resolves to AutomationIrreversibleGateService, which partitions the matched set via the pure gateIrreversibleAutomationActions and hands the survivors to AutomationDispatchService — 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’s automation_runs write path makes blocked persisted 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 one blocked run 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. Consequently AutomationEmissionResult.firedRuleIds means handed to dispatch, never fired — dispatch() stays Promise<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 to UnavailableActionExecutorService, #2361) and is built and tested anyway, because the gate reads a rule’s declared actions rather 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), and automation_trigger_firings — the durable at-most-once record for the two deadline-sweep triggers (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 excludes definitionHash (“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 an ON CONFLICT DO NOTHING against 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; ruleName and trigger are frozen onto the run row at write time — the #2282 attribution-freeze pattern — so an orphaned run still renders, and consumers link by ruleId while displaying ruleName), and not subjectId → order_records (the order_changes / refund_records precedent 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 CHECK on any vocabulary column, and no CHECK on the 1..3 action-list cap: the integration harness builds schema by TypeORM synchronize, 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. And isActive defaults 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.
  • createdAt on 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 the evaluateSalesDocumentRules precedent) takes the trigger that fired, an already-assembled AutomationSubjectFacts projection, the caller-loaded rules and an explicit now — 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 closed AutomationNonFiringReason (the SalesDocumentBlockOutcome precedent, 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. Every AutomationSubjectFacts field is optional and absence means unknown, never false/empty/“no hold” — the same widening ADR-063 made to buyerHasTaxId. A condition over an unasserted fact reads unknown, distinct from false, so the dry run says “we could not tell” rather than “it did not match”. The retroactivity floor obeys the same rule: an unknown occurredAt reports fact-time-unknown and fires nothing, because the wrong guess buys 40 labels. The floor is a FIRING rule, not an evaluation rule, so enforceRetroactivityFloor can waive it — defaulting to true so 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 report fact-precedes-rule for 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’s matched list.
  • 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 reports illegal-trigger-action-pair rather than throwing. satisfies on 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 that holdReason is offered only for T1/T2/T3, and is deliberately declared-only: such a condition on an order.packed rule already resolves correctly through the ordinary path (the facts assert no hold reason, so it reads unknown and 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 a holdReason condition on an order.packed rule 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_runs write path and the per-step outcome shape (the sync_jobs link for a step that dispatched a job rides inside that steps jsonb, 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; DemoAccountCleanupService is the repo’s one Postgres row-deletion retention precedent to mirror.

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 .env and restarting the worker - and since #2279 moved the scheduler into the worker, OL_MASTER_PRODUCT_RECONCILE_CRON set 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 singleton operational_settings row, the third instance of the shape ai_provider_active_setting (#451/#452) and reporting_currency_setting (ADR-040) established.
  • The env rung answers for THIS process, and the answer says so (#2660 review). OperationalSettingsService runs in both the api and the worker, which load their own .env and carry separate environment: blocks in docker-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 setting OL_PRODUCT_SYNC_PAGE_LIMIT in apps/api/.env (the file whose .env.example they 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 carries workerMayDiffer (source !== 'setting', since only the shared ROW is authoritative for both processes), the page states it once above the panels, and both .env.example files now say which process reads each variable. The rung is reported rather than dropped, because dropping it would answer default for 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_CRON are settable and apps/api/.env.example ships 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) and CORE_CAPABILITY_TASKS consumes the same constants as its defaultCron, 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 - the ReportingCurrencySettingsView.source shape). Every column is nullable and NULL means “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 render 500 (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() inside execute(), 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 reason MultiProviderAiCompletionAdapter reads 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 two execute() 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, so SchedulerService snapshots the resolved cadences in refreshOperationalSettings() - awaited by SchedulerLeaseCoordinator immediately before start() - and a cadence change applies at the next scheduler start (a lease hand-over or a restart). The response states cadenceAppliesAt: '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 its cronEnvVar / defaultCron resolution. Only master-product-reconcile declares a cadenceFromSettings; widening the surface to every task is a separate decision.
  • Two kinds of ceiling, deliberately not conflated. OPERATIONAL_SETTING_BOUNDS carries a recommendedMax (advisory - OUR judgement, exceedable) and an absoluteMax (refused whatever the request says), each with a reason string the UI renders instead of inventing one. A write above the recommendation needs acknowledgeAboveRecommended: true on 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’s bounds block (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 uses absoluteMax, never recommendedMax: 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 @Max is 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 sweepPageSize at 100 on the stated grounds that it was “PrestaShop’s collection page size and WooCommerce’s hard per_page cap”. Both halves were wrong for that value. PrestaShop pages through the limit=[offset,]count comma syntax with no cap (prestashop-query.builder.ts; #2644 measured limit=99000,500 against 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’s per_page cap, which IS real, does not apply to that value at all: it is the batch child’s groupSize, never a per_page. The genuine per_page carrier is the enumeration page size, and WooCommerceProductMasterAdapter refuses an enumeration above it (exceedsAdapterPageSize) rather than clamping - a refusal being the only safe answer THERE, because readPagedIds infers 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.reconcile the deletion authority, so switching it off silently reopens #1689 - a deleted product whose offers keep selling. OperationalSettingsInput therefore carries a cadence and no enablement, the view reports deletionAuditAlwaysEnabled: true so 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 accepted 0 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_ENABLED remains the only off switch. A stored cadence is also re-validated on the way OUT, falling back rather than handing SchedulerService an expression that makes new CronJob throw - 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_SIZE and OL_INVENTORY_SYNC_BATCH_SIZE are 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.rateLimit is per-connection, already validated by ConnectionService.validateRateLimitConfig and already edited by RateLimitSection on the connection form (#1810). Adding it here would give one value two homes.
  • syncJobRetentionDays / syncJobDeadRetentionDays (#2946, D16) joined the same generic OPERATIONAL_SETTING_KEYS numeric-knob shape this section describes — bounded 30-365 days, no separate advisory ceiling (recommendedMax === absoluteMax for 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.

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_mappings is 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 invariant fulfilled + cancelled <= total ships here as the pure checkFulfillmentWorkLineCapacity and becomes the DB CHECK in #2392 — the rule exists once, before the column does, and the two move together.

  • supportedActions is 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 a short_picked progress event; on_hold is reached only indirectly, via the hold action’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 / inventory service. Order data enters as ARGUMENTS — which is why FulfillmentWork.orderId is a plain internal-id string rather than an Order — 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’s ZERO_SIBLING_EDGE_LEAVES allow-set, so taking that route additionally requires a one-line registration there — per-leaf and deliberate, never a free ride. Enforced as a prohibition by scripts/check-no-injection-contracts.mjs, whose source-text scan cannot see ModuleRef.get(TOKEN, { strict: false }) (an idiom InvoiceService already uses); the complement is apps/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), for FulfillmentHold.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 on order-lifecycle continuing to export no NestJS module. Note the leaf can borrow the TYPE but not the isHoldReason GUARD: a guard is a value import, which the walker forbids from a registered leaf unconditionally (automation narrows this same union precisely because it is not a leaf), so the repository casts that one column at the boundary — the ReturnLine.custodyState precedent — rather than narrowing it.

  • Persisted since #2392 (ADR-054): three tables — fulfillment_works, fulfillment_work_lines and fulfillment_holds — plus FulfillmentWorkRepositoryPort. Five properties are load-bearing. (1) There is no save(work). REVIEW C10 calls fulfillment_works a five-writer table (router #2395, executor handshake #2399, progress ingress #2400, operator actions #2406/#2410, sweeps), so a full-row save would reproduce the order_records multi-writer problem at a hotter grain. Every mutation is a narrow conditional UPDATE answering affected > 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 than toOrm exclusion, since they are settable at create (ADR-054 R1 creates work ALREADY ASSIGNED) and immutable afterwards. create takes an opaque caller-owned transaction handle (deliberately not an EntityManager — engineering-standards.md § Domain Layer Independence forbids the domain layer naming framework code) so #2395 can commit N work rows and the order’s terminalisation in one transaction. (2) false means “not applied”, not “error” — two writers racing is the steady state; only releaseHold throws, 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_capacity and the pure checkFulfillmentWorkLineCapacity are 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 @Check is declared class-level under the migration’s exact name, because the integration harness builds schema by synchronize and an anonymous one carries a hash name there. (4) The ≤10 active-hold cap is repository-level, not a DB constraint, and it needs a FOR UPDATE lock on the PARENT work row. Counting inside the transaction enforces nothing at READ COMMITTED: two concurrent placeHold calls each see nine, both insert, and the work carries eleven active holds silently with no error. A plain SELECT takes no locks, and the offending rows are a phantom — so FOR UPDATE on fulfillment_holds cannot 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, which synchronize does not emit — it would hold in production and silently not in tests. (5) FKs are per relation: fulfillment_work_lines and fulfillment_holds take real ON DELETE CASCADE foreign keys to their parent (migration-only, no @ManyToOne — a line is a part of its work), while orderId, productVariantId and assignedConnectionId are indexed references by value with none, so a deleted connection cannot cascade away fulfilment history and order_records’ lines-in-jsonb shape is respected. Because the synchronize-built test schema has no FKs at all, CASCADE cannot be exercised by an ordinary int-spec — so #2392 also ships fulfillment-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 the synchronize-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 while synchronize minted hash names. A context adding tables should copy it rather than rediscover why it exists.

  • Driving the negotiation: the handshake, assignmentAttempt and fulfillment.work.dispatch (#2399, DESIGN §5.4, ADR-054): FulfillmentHandshakeService offers a routed work object to its assigned holder and records the answer, driving unsubmitted | rejected -> submitted -> accepted | rejected and accepted -> 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 retries work:{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 MCF sellerFulfillmentOrderId model). claimDispatchAttempt moves the status and increments the counter in ONE conditional UPDATE and hands the attempt back through that statement’s RETURNING, 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’s incrementAssignmentAttempt, whose WHERE was "id" = :id alone: with no state guard any caller could bump the counter out from under a live submitted dispatch 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 reach requestFulfillment; 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. null means “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 moves requestStatus without coming through recordAcceptance. acceptedAt stays null when the holder reports no instant, so at-most-once comes from the conditional UPDATE and never from the column being populated. externalWorkId is stamped alongside it, admitted deliberately off the accepted arm’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. blocking exists 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_rejections carries connectionId (a refusal that does not say WHO excludes nobody, and it is captured at refusal time rather than read back from assignedConnectionId, which clearHolder may since have moved) and a denormalised orderId, which is a deliberate refusal to bind #2395: if re-sourcing mints a NEW work row, a workId-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 is workId-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 cached accepted - the replay guarantee working exactly as specified - and OL would record cancellation_accepted for 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 inject IIntegrationsService (a VALUE import from a sibling) nor any orders service (ADR-053’s no-injection invariant); both resolve in apps/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. deliveryMethod is deliberately NOT an argument: it is an insert-only column the router owns, and a second source could disagree with the row. The job is fulfillment.work.dispatch in the realtime lane (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 - the marketplace.order.sync class, not a paced sweep; #2594’s split-by-trigger has nothing to separate, so the type stays single), with SyncJob.connectionId set to the work’s own assignedConnectionId and 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 is ok, a holder rejection is a terminal business_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 with getEnvBoolean('OL_STORE_PII', true) and not getPiiConfig(), which throws whenever OL_PII_HASH_SALT is unset regardless of the flag and would break dispatch on every ordinary deployment; the hashed arm’s locationHash is honestly null, because the ingestion-time customer_address_projections.addressHash still 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.v1 advertises FulfillmentExecutor alongside OlFulfillmentExecutorAdapter, so A3 is assignable in the UI rather than only through a direct PATCH /connections/:id, and fulfillment.work.dispatch resolves 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, which IDX_fulfillment_works_request_status was 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.timeoutSweep converts a submitted work whose holder never answered into rejected, which is the re-routable state (FulfillmentHandshakeService’s CLAIMABLE_FROM already 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 in submitted for ever and re-sourcing could never begin. Six properties are load-bearing. (1) A timeout rejection is never blocking, and that is the decision the whole slice turns on. blocking excludes 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 loop blocking exists 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 of requestStatus - it reaps THROUGH the unchanged recordRejection, so the guarded submitted -> rejected transition 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 is requestStatus = 'submitted' AND updatedAt < cutoff and 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.ts draws 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 no MasterSweepKind member is added because sweepLockKey renders master:{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-entered submitted inside the read-then-write window would otherwise take a rejection naming an attempt that is no longer live. The submitted guard 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 - deriveAcceptanceAttention reads EVERY work object on the order, so it is split-safe (omsAttention is 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 through IOrderRecordService.markOmsAttention (#2352’s first production writer): this leaf may not inject an orders service, and the evidence the split is right is that #2712 adds zero entries to check-no-injection-contracts.mjs’s or barrel-purity.spec.ts’s allow-sets. bulk lane (a cron-paced reconciler over work already stalled by definition, unlike its two realtime fulfillment.work.* siblings), global scope under the nil-UUID system connection, hourly at 35 * * * *, default ON - it makes no platform call and is inert on any install that has not opted into routing, since fulfillment_works is 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.dispatch resolves its executor through getCapabilityAdapter, which is gated on enabledCapabilities, and it does so BEFORE the handshake claims the work. So an OMS connection without FulfillmentExecutor enabled made every dispatch job dead-letter, leaving the work unsubmitted for ever with no alert. That connection is reachable two ways: one created between #2405 and #2409 (enabledCapabilities is never back-filled, #2085), or an operator unticking the capability. Three parts close it. (1) Refused at assignment: createOmsFulfillmentRouterResolver reads the capability and hands the router a REQUIRED holderCanExecute. On false, 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 than null, because null would mirror the order to every product master. The committer refuses the plan, the order is held routing-refused, and #3485’s reroute sweep routes it once the capability is enabled. The router’s per-line reasons now ride on the refused outcome (unfulfillableReasons, distinct and sorted so the detail is stable across re-routes) into fulfillmentBlock.detail and the UF-L detail. Before this the operator saw only plan-carries-unfulfillable and could not tell “out of stock” from “the holder cannot execute”. (2) Surfaced: the #2712 timeout sweep gains a second pass over live, holder-assigned unsubmitted work idle past the same dispatch timeout, and raises A3-X through deriveAcceptanceAttention’s opt-in stalledUnsubmittedBefore arm. 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: buildFulfillmentDispatchDedupeKey takes an optional generation, and the pass re-enqueues the dispatch under fulfillment:dispatch:{workId}:redrive:{floor(now / timeout)}. It does so only once the holder’s connection is active with FulfillmentExecutor enabled, 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.relaySweep re-drives the lifecycle relay for a FulfillmentWork a holder reported SHIPPED whose dispatchRelayedAt is still NULL. #2401 made that claim RELEASABLE, which frees the SLOT and causes no retry: IFulfillmentProgressService.record burns 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 answers already-relayed, so recovery would need a THIRD. If the holder’s next event is delivered, 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_changes on 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.ts fails 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 THROUGH claimDispatchRelay, never around it - the sweep resolves candidates and hands each to the shipment-first IFulfillmentWorkDispatchRouterService.routeDispatch (core/shipping, #3506), whose two paths both take the work’s one conditional slot claim (claimDispatchRelay inside the unchanged relayDispatch, or markRelayedExternally after a shipment-grain notify lands), so the conditional UPDATE stays the serialisation point between this pass and any concurrent progress-driven trigger, exactly as Shipment.waybillRelayedAt is 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’s eventKind, which has no alternative source - FulfillmentProgressService.apply’s shipped arm 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. A shippedAt column on fulfillment_works was declined - a sixth writer on a five-writer table, a second source of truth, and backfillable only from the same eventKind read, 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, because claim’s bare ON CONFLICT DO NOTHING is 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 acquiring dispatchRelayedAt, 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 no MasterSweepKind member is added (sweepLockKey renders master:{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) bulk lane, 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 queued fulfillment.work.dispatch. Global scope under the nil-UUID system connection, hourly at 50 * * * * offset from the timeout sweep’s 35, default ON - it writes no row of its own, so a spurious run’s worst outcome is already-relayed. (6) The escalation is an AGE bound, counted and logged rather than persisted (decision 4, the #2346 shape): past OL_FULFILLMENT_RELAY_STUCK_AFTER_MS a candidate is counted on the job result and error-logged under fulfillment_relay_reconcile_stuck, and is still re-driven, since adapter-unresolved is 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: FulfillmentWorkView excludes 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.record now has a production caller (FulfillmentParcelClosureNotifierService, the bench close + the desktop worklist’s manual close), so a shipped claim IS created whenever an OL-executed parcel closes, and this frontier can find real candidates once FulfillmentDispatchRelayService.relayDispatch fails transiently for one. Review correction (#3525): a work whose closure routed through the SHIPMENT-grain path never enters this frontier at all. For the ordinary ol_managed_carrier case - a bought label, #2402’s fulfillmentWorkId link, still generated - the notifier drives IShipmentDispatchNotificationService.notifyDispatched instead of this work-grain relay (tracking number + carrier, and it advances the shipment, feeding #2347’s reservation consumption), and claims dispatchRelayedAt directly via the gate’s new markRelayedExternally on 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 shared FulfillmentWorkDispatchRouterService, 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-less dispatched - the channel showed SENT with no waybill, the slot was burnt and the shipment stayed generated until an operator clicked notify-dispatched. Now a shipment-grain re-drive that lands advances the shipment and claims the slot (via-shipment, counted relayed), and one that fails again leaves the slot open (shipment-failed, counted released) so the work stays on the frontier and, past the stuck bound, escalates - it never degrades to the tracking-less relay. The manual trigger POST /v1/sync/jobs accepts the nil UUID (@IsUUID('all')), so a hand-run sweep takes the same fulfillment: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 - neither FulfillmentShippedEvent nor FulfillmentProgressSnapshot carries a tracking number and no executor adapter reports one - and the premise that a router-fulfilled work has no Shipment to key on is wrong for the ol_managed_carrier topology 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, NOT shipment_lines (#2402, W3a-13) — #2392 deferred this column here, and #2402 resolved that it lands on shipments. Both #2392’s AC and DESIGN §5.2 name a shipment_lines table, and there is none — libs/core/src/shipping declares exactly one entity, @Entity('shipments'). Three things decided it. (1) §5.2 contradicts itself in one sentence: “Relationship to Shipment is 1:N with the shipment keeping its identity; shipment_lines gains a nullable fulfillmentWorkId” — 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_lines is a different concern with a different key and motive — option C of DECISION-oms-fulfilment-grain, keyed (shipmentId, orderId, lineId, quantity) with orderId present so consolidated shipping across two orders stays expressible, existing to make shipped_quantity derivable. It carries obligations #2402 has not (a backfill written as ledger events, the fulfillment-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 — the order_changes / refund_records / returns.internalOrderId cross-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 sibling IDX_shipments_reservation_consume_pending on 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 the IS NULL scan 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, and claimFulfillmentWorkLink (WHERE "fulfillmentWorkId" IS NULL, the claimWaybillRelay idiom) 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 no assignmentAttempt companion: 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’s connectionId is the order-source / OMP marketplace connection, while a FulfillmentWork carries assignedConnectionId, the executor holder; those coincide on an omp_fulfilled topology and diverge on a routed ol_managed_carrier one, 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, so FulfillmentWorkLinkResolution names ambiguous as 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, ShipmentDispatchService and FulfillmentStatusSyncService.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 a providerShipmentId; shipment-creation-sites.spec.ts pins the list — which is the honest reading of its AC, since the AC as written was already false on main. A 3PL therefore never gets a fabricated providerShipmentId, because dispatch() resolves omp_fulfilled and 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 optional ShipmentDispatchInput.fulfillmentWorkId had no caller anywhere in the tree — no HTTP DTO, no worker handler, no bulk site — so the entire ol_managed_carrier half of the bridge would have been unreachable, exercised only by its own spec, on exactly the topology the order-only lookup exists to serve. ShipmentDispatchService therefore resolves the link through the same IFulfillmentWorkQueryService the 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 and diffPatch never opens it again — free today since nothing reads the column, but #2727’s rollup will need a backfill. Deferred: an automatic shipped→dispatch() trigger is #2729 — ShipmentDispatchInput requires recipient and parcel, and while a pure recipient projection exists in apps/web (features/orders/lib/dispatch-input.ts, unusable from core per #591 — a mirror-or-move decision, not reuse), parcel weight 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.ts from 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’s FULFILLMENT_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.statusSync and IFulfillmentProgressService (#2400, DESIGN §5.4/§5.5): progress had a contract and no transport. CanonicalInboundEvent.domain is 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 on FulfillmentExecutor; 'return', gated on OrderSource), each with a resolveRoute arm, and the never-exhaustive default: 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, which scripts/check-no-injection-contracts.mjs and barrel-purity.spec.ts independently forbid under this directory — and that prohibition is ADR-053’s report-don’t-perform discipline (the #2100 SalesDocumentBlockOutcome seam), not an obstacle routed around. FulfillmentProgressOutcome carries FulfillmentRelayIntent[]; 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 a short_picked is reported as an intent, because re-entering route() needs #2395’s router, the routing lock and order.cancelledAt, all outside this context. (2) The dedup index is UNCONDITIONAL, and the enforcement is the index. fulfillment_progress_claims carries 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 #2360 INSERT … ON CONFLICT DO NOTHING / raw.length > 0 idiom, never a SELECT-then-INSERT, which at READ COMMITTED enforces nothing because the conflicting row is a phantom that cannot be locked before it exists. The bare ON CONFLICT (TypeORM 0.3.17’s orIgnore discards 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 inside record() 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 answering false becomes a named precondition-failed, never a throw — the FulfillmentWorkRepositoryPort convention that false is an ordinary outcome. (4) This handler still calls record() for nobody, deliberately — but the seam has its FIRST production caller since #3525. apps/api’s FulfillmentParcelClosureNotifierService calls record({kind: 'shipped', ...}) from BOTH places a parcel can close (the pack bench’s automatic close and the desktop worklist’s manual close action) — 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 polling FulfillmentStatusSource below. This particular handler (fulfillment.work.statusSync) still acknowledges the trigger and completes without calling record() itself: the only data an inbound webhook offers is CanonicalInboundEvent.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 why FulfillmentWorkStatusSyncPayloadV1 carries a reference and no deltas. #2398’s FulfillmentStatusSource is 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.statusSync is NOT marketplace.fulfillment.statusSync (#834, the shipping context’s OMP read-back) — unrelated job, service and meaning; the OMS one is namespaced fulfillment.work.* on the inventory.reservations.* precedent because its trigger is an executor and not necessarily a marketplace. It runs on realtime: ADR-050 picks by cost of starvation, and a progress report is waited on by a picker at a station. awaiting_wave is absent from the event union and named as its first extension point (the ADR-045 packGrain lesson). Resolving a vendor’s own reference to an OL workId is #2399’s, which owns the executor handshake and therefore knows whether that reference is per-connection or intrinsic to the row; record() takes a workId — an internal id entering as an argument, ADR-053’s stated discipline — so no externalWorkId column 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 on OrderSource is correct — ReturnSourceReader is guard-only, absent from CoreCapabilityValues, so enabledCapabilities can never contain it and gating on it would leave the arm permanently ungated (the #2085 stamped-at-create trap). The cost is that a plain OrderSource connection with no return reader (PrestaShop, WooCommerce) passes the gate and enqueues marketplace.return.sync, which then fails at the narrow. That failure used to be a bare Error the handler rewrapped as retryable, spending the full ten-attempt ladder and a dead row on a structural condition no retry can change. ReturnIngestionService now raises the named ReturnSourceNotReadableError and the handler answers a terminal business_failure (ADR-007) — the treatment ReturnObservationMissingExternalIdError already had beside it.

  • How a router is ASKED, and what it is allowed to see (#2393, DESIGN §5.3, ADR-062 Decision 2): FulfillmentRouterPort carries two methods — evaluate() (non-committing) and route(input, options) (committing) — plus the routing I/O that crosses them. Five properties are load-bearing. (1) The return type can say no: RoutingPlan.unfulfillable resolves 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, since RoutingEvaluation carries no decisionId (so nothing can be persisted off an evaluate result) and evaluate takes no options (so it cannot be handed an idempotency key). ADR-044 records that nothing else planned produces a dry run. (3) RoutingInput is 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 the Order entity 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 a tsc error 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 read ICustomerProjectionService does not have). Under OL_STORE_PII=false the degraded arm carries countryIso2 plus a caller-supplied locationHash — the ingestion-time customer_address_projections.addressHash — and NOT a hash recomputed here, because the persisted snapshot’s address has already been through redactAddress, so hashing it would yield one value per COUNTRY, silently grouping the whole install behind a plausible 64-hex string. A missing hash reports null rather than a fabricated grouping key, and the country survives in both arms because a country code is not PII and country-served is 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) RoutingPlan declares a second, pending {decisionId} arm (REVIEW G5’s third) for a genuinely asynchronous DOMS (REVIEW G5) and Wave 3a REFUSES it with the named PendingRoutingPlanNotSupportedError — treating it as resolved would proceed with no assignments and read as a router that successfully decided to create no work; consuming it is W4-3. FulfillmentRouter is deliberately absent from CoreCapabilityValues and from every manifest (#2403 — A2 is config-only): nothing can dispatch the name, and advertising it would invite a gate on enabledCapabilities, which is stamped at create and never retro-filled. Note the port is not the FulfillmentRouting* family in libs/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 declared maxBatchSize and 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): FulfillmentExecutorPort carries requestFulfillment and requestCancellation, plus the optional FulfillmentStatusSource sub-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) blocking on 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?: boolean reads undefined, 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); a void cancellation would assert a compliance the contract cannot obtain. The two result arms deliberately spell accepted / rejected exactly as that negotiation axis does, since #2399 stamps FulfillmentWork.requestStatus straight 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) FulfillmentRequest is an explicit ADR-062 allowlist that reuses RoutingShipTo rather than declaring a second ship-to shape - so this port opens no new PII surface and spends no new ZERO_SIBLING_EDGE_LEAVES entry; 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 holds identifierMapping in its HostServices bag 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 - assertFulfillmentRequestResultRecognised tests 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 yield blocking: 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, unlike assertRoutingPlanResolved’s unconsumable pending. (6) A holder-reported instant is the HOLDER’s (#2336/#2367/#2371) - acceptedAt and observedAt are null when unreported, never new 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. FulfillmentStatusSource is advertised-without-dispatch, narrowed by a runtime method probe (ADR-055 R1 / the ADR-046 resolver precedent, so an adapter compiled against an older libs/core degrades rather than throwing) and absent from CoreCapabilityValues - a spec pins that absence beside its base port’s presence. Its method is getWorkFulfillmentStatus, deliberately diverging from DESIGN §5.4’s original getFulfillmentStatus (the design carries the amendment): orders ships FulfillmentStatusReader.getFulfillmentStatus({externalOrderId}) whose guard is a bare typeof 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 by FulfillmentStatusSyncService with {externalOrderId} while expecting a FulfillmentWorkRef. That is precisely the ADR-046 isOfferFieldUpdater and #2229 isEanCategoryMatcherStreaming shape, 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 that FulfillmentRequestResult carries a per-arm allowlist of its own: the inbound/outbound distinction bounds PII exposure but not PERSISTENCE, and #2399 stamps requestStatus from this result, so an unreviewed field a plugin returns is a field core may write (the #2327 rawPayload class). Per ARM because keyof (A | B) is the intersection, so one list checked against the union would see only status and pass whatever either arm grew. FulfillmentExecutor IS in CoreCapabilityValues (#2403), unlike FulfillmentRouter, because A3 resolves by narrowing a dispatched adapter and its name must be assignable - and openlinker.oms.v1 advertises it since #2409, which closed the reachability gap that had made A3 assignable only through a direct PATCH /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, and IFulfillmentProgressService.record (#2400) is its seam; the accept handshake and the assignmentAttempt counter 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: no oms_* table, no TypeORM dependency in libs/oms, no migration, and no injected deps - so OmsModule stays on createNestAdapterModule and does not pay the ADR-051 cost #2405 predicted (a hand-written @Module would drag ShippingModule / MappingsModule providers into the events, scheduler and maintenance worker roles). (2) Auto-accept goes THROUGH the real handshake claim, never around it - nothing in the adapter writes a row; FulfillmentHandshakeService claims the attempt via claimDispatchAttempt (whose attempt reaches the caller only through RETURNING, so minting a key ahead of its row is inexpressible) and records the answer through the guarded recordAcceptance. “Auto-accept” names the ANSWER, not a shortcut past the negotiation. (3) acceptedAt is null, and it is FORCED rather than chosen. OL is the holder here so its clock would be a legitimate witness, but #2404’s contract calls requestFulfillment twice under one key and requires a byte-identical outcome - so a fresh Date per call fails the contract, and the only replay-stable instant an executor without its own store can offer is null (a documented first-class value, told apart from “never accepted” by requestStatus). One consequence is reported rather than buried: recordAcceptance is guarded on "requestStatus" = 'submitted' AND "acceptedAt" IS NULL, and writing null makes that second conjunct non-narrowing for OL-executed work, so at-most-once acceptance rests on the requestStatus conjunct alone - not a live defect, since nothing else writes that status, but a defence-in-depth layer thinned. (4) It deliberately does NOT implement FulfillmentStatusSource, 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, so getWorkFulfillmentStatus would read fulfillment_works’ own counters and report them back as observed progress, which IFulfillmentProgressService.record then writes to those same counters - a closed loop, and the second-source-of-truth the design exists to avoid. So #2409 does not become record()’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) — via FulfillmentParcelClosureNotifierService (#3525), which IS record()’s actual first production caller. Two things this makes reachable are worth knowing: A3 becomes assignable in the UI for an openlinker connection, and fulfillment.work.dispatch can now resolve an executor instead of raising its retryable “could not resolve” error - neither activates on its own, since an OMS Connection row 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 (requestCancellation guarded on requestStatus === 'accepted' alone, which for OL work stays accepted for the work’s life), so a cancellation against already-shipped work answered accepted. FulfillmentHandshakeService.requestCancellation now checks isTerminalFulfillmentWorkStatus(work.status) BEFORE crossing the port at all and answers the handshake’s own not-cancellable outcome (a member of FulfillmentHandshakeOutcomeValues distinct from no-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, matching deriveSupportedActions’s own !terminal rule for offering request_cancellation in 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 at libs/core/src/fulfillment/testing/, reached through a new @openlinker/core/fulfillment/testing package-exports subpath, and covers two real ports today: FulfillmentRouterPort (#2393, 9 cases) and FulfillmentExecutorPort (#2398, 7 base cases + 4 for the optional FulfillmentStatusSource). 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. AvailabilityAuthority gets 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 an it that 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-case checks > 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) The checks counter is the SECONDARY guard and says so — it is self-reported, so check(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 — never it.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 when isFulfillmentStatusSource narrows the subject, expectedFulfillmentExecutorContractCaseIds is 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 in libs/core that 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: blocking exclusion is dropped for the ROUTER (that port has no such concept) and asserted for the EXECUTOR (fulfillment-execution.types.ts property (a)), and declared-timeout respect is dropped from both because the ports defer wall-clock budgets to W4-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 by scripts/check-contract-suite-not-in-production.mjs under pnpm check:invariants rather than left to reviewer vigilance — a runtime require() reaching this code dies with describe is not defined at the far end of a stack trace that explains nothing. Placement inside the context (rather than in libs/test-kit) follows the two shipped precedents, imports only same-context relative paths so it spends no new ZERO_SIBLING_EDGE_LEAVES entry and adds no package dependency, and keeps the kit inside check-cross-context-imports.mjs’s walk, which does not cover libs/test-kit at all.

  • The router is REACHABLE, and reachability moved the seam out of orders (#2408): resolveFulfillmentRouter returned null unconditionally, so no RoutingDecision was ever committed, no FulfillmentWork was ever created, and Wave 3a’s exit criterion was unreachable on main - RoutingCommitService is the ONLY caller of FulfillmentWorkRepositoryPort.create in the tree, so the whole layer was inert rather than merely unused. The answer now arrives through FulfillmentRouterResolverPort (resolve(connectionId)), bound host-side to ONE implementation, createOmsFulfillmentRouterResolver (@openlinker/oms), which returns createOlFulfillmentRouter for a connection whose platformType is openlinker and null for 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 - while libs/core/src/orders may 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 the fulfillment.work.route handler, 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 to null makes 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, the assertFullLaneCoverage posture. (3) @Global() publishes a module’s EXPORTS, not its providers, so each binding module exports the token - without that line it is invisible to OrdersModule, where OrderIngestionService is declared and where the injection resolves from. The binding module imports the HOST’s IntegrationsModule wrapper, never the bare PluginRegistryModule (a @Module({}) shell with a static forRoot, so importing the class yields an empty module and ROUTING_RULE_SOURCE_TOKEN never resolves, while calling forRoot again would double-register every plugin). (4) The discriminator is platformType, not adapterKey and not the A2 claim. A2 is config-only, so ANY connection may claim sourcingAuthority (#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 as plan-carries-unfulfillable: a configuration mistake wearing the costume of a routing bug. adapterKey is rejected because an openlinker.oms.v2 adapter would leave every existing connection pinned to v1 and silently stop resolving. (5) The three null causes are told apart in the log. Both call sites emitted one log-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 two warn from the resolver, where the cause is known. The degenerate default is unchanged and still pinned by four characterisation tests in order-ingestion.service.spec.ts - a router-less install is byte-identical, including the whole-object toEqual on the syncOrder request. 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 the fulfillment.work.dispatch producer with #2955 - see the two bullets below.

  • The router counts live stock only (#3481, epic #3460): OlFulfillmentRouter.loadStock read positions through IInventoryQueryService.listInventoryItems -> InventoryRepository.findMany, the one inventory read with no isStale = false filter, so routing counted rows the #2322 / #3206 repairs had staled (the located row left behind after stockLocationOverride is cleared) and rows of products deleted at the master (#1689) - units that do not exist, committed as work nobody can pick. InventoryFilters.excludeStale is an OPT-IN filter the router now always sets; absent keeps every row, which is exactly the pre-#3481 behaviour every other findMany caller relies on, because the operator-facing inventory list shows stale rows on purpose. Repository-level only, like sourceConnectionId: 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.loadStock still read raw availableQuantity, so two orders for the last unit could each be given a FulfillmentWork for it. It now subtracts, per (location, variant), the live non-stale held reservations stamped atpEffect = 'published' - the same definition IAvailabilityService subtracts (ADR-061 § Amendment (#3475)), read through the new IInventoryQueryService.listPublishedReservedByLocation rather than by making AvailabilityScope location answerable. Four properties are load-bearing. (1) Published holds only - work lines are deliberately NOT subtracted: a routed order’s hold is published (#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; a blocked / in_doubt / retryable decrement 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 fails route() and evaluate() - never read as zero - and the commit path maps it to the re-routable routing-failed hold (#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 of InventorySaleDecrementService leaves 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): RoutingRuleAdminPort is the operator-authoring half of oms_routing_rules, beside the router’s read-only RoutingRuleSourcePort - 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 at connections/:connectionId/sourcing-rules, admin-gated, with GET/POST/GET :id/PATCH :id/DELETE :id plus an exhaustive PUT /order. Before it, ROUTING_RULE_SOURCE_TOKEN had 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 is sourcing-rules, never routing-rules - apps/api/src/mappings/http/fulfillment-routing.controller.ts already mounts the latter for the ADR-012 dispatch surface (#836), and fulfillment-router.port.ts forbids wiring one into the other. Sharing the namespace would declare GET /connections/:id/routing-rules twice; NestJS registers both and the first wins SILENTLY, so one of the two surfaces becomes unreachable with no boot error and nothing failing, since route-authorization-coverage.spec.ts checks decorators rather than path uniqueness. The response DTOs are SourcingRule* for the same class of reason one level down: @nestjs/swagger keys schema definitions by CLASS NAME, so a second RoutingRuleResponseDto would silently overwrite the first in the published OpenAPI document. (2) ORDER IS STATED, never derived from insertion time - position is REQUIRED on create, and PUT /order renumbers to a dense 1..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 a FOR 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 through coerceRoutingRule - the same function the read path narrows with. A per-field @IsIn cannot 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 a name moved 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’s effectiveTo IS NULL - a rule whose retirement is scheduled in the future is evaluated by listActiveRules right 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, flagged recognised: 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 both connectionId and ruleId, because a 403 confirms that a guessed id names a real rule somewhere. Two removal paths are offered distinctly: DELETE is a hard delete including history, while PATCH { 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 whose platformType is not openlinker (the discriminator createOmsFulfillmentRouterResolver uses), 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. resolveRoutingTarget now returns the pass-through BEFORE router selection when the order’s SOURCE connection has ProductMaster in its enabledCapabilities (isOrderFromOwnProductMaster, orders/domain/types/fulfillment-routing-eligibility.types.ts). Three properties are load-bearing. It is capability-driven, never a platformType test. 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 unfiltered connections.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, resolveRoutingTarget returns the pass-through when the order’s ADR-012 fulfilment routing (IFulfillmentRoutingService.resolve) resolves to omp_fulfilled (isOrderShippedElsewhere, same file as #3487’s rule). Three properties are load-bearing. Only a matched RULE counts: resolve answers omp_fulfilled with source: '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’s atpEffect (#2344), so the two readers cannot disagree about one order and no extra statement is spent when both run. processorAvailable is 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.fulfilledBySource is a three-state fact like buyerTaxId (#2599) - true is a positive statement, absent means “not asserted” and is never read as “the seller ships”. AllegroOrderSourceAdapter sets it only when the checkout form reports fulfillment.provider.id = 'ALLEGRO' (One Fulfillment, per the Allegro swagger’s CheckoutFormFulfillmentProvider); SELLER or an absent provider leaves it unset. It is persisted on the order snapshot, and resolveRoutingSkipReason skips such an order as source-fulfilled before 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 explicit omp_fulfilled rules 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.syncStatus carries a synced row (isOrderMirroredBeforeRouting); persistOrder never writes syncStatus (#2140), so that read is current and costs no statement, and only synced counts, since pending and failed mean 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 a fulfillmentBlockReason value - every value there means held, not mirrored, while a skipped order follows today’s path - and it answers a different question from #3014’s destinationRoutingBlockReason. (2) It is recorded only while a connection claims A2: router selection now runs first, and a no-claimant answer 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 including null, outside the ingestion write set, behind an IS DISTINCT FROM guard so the common null -> null path costs no updatedAt bump, and left untouched when the routing resolution could not decide. (5) All three rules are decided in resolveRoutingTarget, never later in the intercept - that one answer, resolved before the advisory hold is written, also decides the hold’s insert-only atpEffect (#3480), so a skipped order is never held published; 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: RoutingCommitService lives in the fulfillment leaf, which may not read orders, and fulfillment.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. Once resolveRoutingTarget answers route, every outcome now keeps the order in OpenLinker. Five properties are load-bearing. (1) One rule, two routing sites: deriveRoutingHoldOutcome (fulfillment leaf, pure) maps a RoutingCommitOutcome onto { held, block, lineAttention } and is used by both the ingestion intercept and the fulfillment.work.route handler, so the two cannot disagree about one order. A refusal is held as routing-refused, an address-less order as routing-no-shipping-address, and an error after the router was selected as routing-failed (three new FulfillmentBlockReason values; the column is text, 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’s routing attention entry (line-unfulfillable, counted in Needs attention), a routed order clears it, and every arm outside an OMS routing attempt reports indeterminate - 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 the IS DISTINCT FROM guards and bump order_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 to abandoned, which frees the live-decision index, so a later route() mints a fresh decision. fulfillment.work.rerouteSweep (bulk, global scope, default ON, every 15 minutes) is fulfillment.work.route’s first producer: it enqueues one route job per order held as routing-refused / routing-failed under 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 on internalOrderId from a connection_cursors cursor, 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-address is 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: routedByOms is now routingTarget.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-only published stamp survives the later route and the #3453 sale decrement consumes it. (5) Visible: OrderRecord.fulfillmentBlock reads the previously write-only fulfillmentBlockReason / 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.dispatch was fully built - handler, lane registration, FulfillmentHandshakeService, the assignmentAttempt claim - and enqueued by nobody, so a routed FulfillmentWork stayed unsubmitted for ever and the pack bench, which filters on accepted, was permanently empty. Both routing sites now enqueue one dispatch job per assigned work: OrderIngestionService’s intercept (#2396) and the fulfillment.work.route handler (#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.enqueue in core, JobEnqueuePort.enqueueJob in the worker); a shared request object would have forced one host onto the other’s port. (2) connectionId is the work’s OWN assignedConnectionId, 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 is fulfillment:dispatch:{workId}, its own namespace - never the handshake’s work:{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. Because sync_jobs.idempotencyKey is 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 on buildFulfillmentDispatchDedupeKey). (4) The enqueue is per intent with a per-intent catch, and the producer never throws - enqueueBulk disclaims 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 inside interceptFulfillmentRouting’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 is fulfillment.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) is FulfillmentRouterPort.evaluate()’s first caller - it takes an ingested order or a hand-built basket and returns the router’s RoutingEvaluation, calling evaluate() and never route(), so no decision row, work, hold or idempotency key can result (routing_decisions and fulfillment_works row counts are asserted unchanged against real Postgres). Four properties are load-bearing. (1) A draft is an unsaved ruleset reached only through the guard-only DraftRuleEvaluator sub-capability (libs/oms), not through a parameter on core’s FulfillmentRouterPort - rules are @openlinker/oms vocabulary and REVIEW H7 keeps them opaque across that port; route() has no rule parameter at all. Each draft rule passes through coerceRoutingRule, the function saved rules pass through, so a draft that could not be saved cannot be evaluated either, and the pure diffRoutingEvaluations names the lines it would place differently. (2) The order is reduced to the ADR-062 allowlist by one shared pure projectOrderForRouting (libs/core/src/orders/domain), which ingestion and the fulfillment.work.route handler 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’s reason and each explanation step’s displayLabel reach 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_decisions is OpenLinker’s own record that it is ABOUT to ask a router where an order is fulfilled from, persisted BEFORE the committing route() 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 a route() call is already in flight). The shipped #2047 invoicing guard is the shape being copied. Six properties are load-bearing. (1) The guard is UNIQUE ("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 + releaseShortfall re-enters route() 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 on shipment.orm-entity.ts does NOT govern here: rows leaving the index on terminalisation is the MECHANISM, not a side effect, and the governing precedents are UQ_order_changes_open_target and UQ_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 a committed decision with live work leaves the index free, and that half is #2395’s. (2) Uniqueness is the INDEX, never an application check - claimIntent inserts and translates 23505 into RoutingDecisionAlreadyLiveError, because at READ COMMITTED a plain SELECT takes 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 every 23505 would report a PK collision as “a live decision already exists”. It throws rather than returning the incumbent, unlike the exchange_rates insertIfAbsent shape - 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 no save(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; state is not accepted at create (a claim is live by definition, and accepting it would allow inserting an already-terminal row) and there is no expectedState parameter, because live is the only legal precondition and a choice that does not exist must not be offered. (4) TerminaliseRoutingDecisionInput is a discriminated union whose arms are DERIVED from RoutingDecisionState, 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: abandonReason is refused on the committed arm while routerDecisionRef is legal on BOTH, since #2393’s plan-not-conserving describes a router that answered with a RoutingPlan - and therefore with a decisionId - which OpenLinker then refused. (5) No foreign keys. orderId and routerConnectionId are cross-aggregate references by value (the order_changes / refund_records / fulfillment_works.orderId precedent): 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_order is 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 - deriveRouteIdempotencyKey is 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 #2039 reconcileId lesson). 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_decisions also joins fulfillment-work-migration-parity.int-spec.ts rather than getting a sibling spec - that file already runs the full migration chain once per integration run, and its indexdef comparison 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.

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) plus shipment_line_events (an append-only act ledger). #2402 put fulfillmentWorkId on shipments because 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 no fulfillmentWorkId at all.
  • orderId is part of the line’s IDENTITY, not a payload column. shipments.orderId is 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 sets line.orderId = shipment.orderId today; the column is what lets that stop being true without a migration.
  • The counters are a FOLD; the acts are authoritative. foldCounters is a TOTAL recompute rather than an increment (the reservations / inventory_items.olReservedQuantity shape), 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; S1 dispatched then cancelled, S2 the re-issue. A SNAPSHOT backfill writes S1.shipped = 2 and S2.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 writes S1: ship(2), cancel(2) (net 0) and S2: ship(2) (net 2). The cancel act 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.ts asserts 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_occurred carries 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 — but ShipmentDispatchService persists failed in its generateLabel catch and a retry re-enters on the SAME shipment.id, so failed → generated → dispatched is reachable on one row. Under that key a line that had emitted ship + cancel could 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 bare ON CONFLICT DO NOTHING, with no sequence counter and therefore no lock, unlike the returns (returnLineId, seq) shape), while a genuine re-dispatch carries a new dispatchedAt and correctly emits a second ship. occurredAt is the SHIPMENT’s own instant falling back to its immutable createdAt — never updatedAt, which moves on every write and, being a key column, would mint a fresh duplicate act on every reconcile.
  • CHK_shipment_lines_capacity deliberately OMITS "shippedQuantity" <= "quantity". Two ordinary paths violate it: re-ingestion rewrites orderSnapshot wholesale (a frozen quantity of 2 meets a ship(5) act), and the dispatch retry above folds to shipped = 4, cancelled = 2 on 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. quantity is 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 OPTIONAL FulfillmentQuantityCoverage and 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. dispatched is the honest approximation because FulfillmentRollupState has no partial; adding one would need this union’s SQL twins, the FE deriveFulfillment and 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.recompute is already called from all eight shipment-mutation sites, so no mutation service changed; reconcile takes the shipments that method ALREADY loaded rather than re-reading them, because recompute runs once per shipment inside the two status-sync loops and a reconcile(orderId) that re-queried would be O(S² × I) upserts for a page touching S shipments of one order. Three of those sites sit behind a if (patch.status) changed-patch gate, so a tracking-only backfill (#1947) or a labelPdfRef write 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 calls recompute.
  • Direction is scoped by JOIN, never by a copied column (#2373 / ADR-060): direction lives on shipments and stays there, so every read joins and filters there with direction a REQUIRED parameter carrying no default — the ShipmentRepositoryPort rule 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 on orderId; it never consults fulfillmentWorkId, 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 packer role plus the @Roles('admin', 'operator', 'packer') decorators naming it. That is why ROLE_PERMISSIONS.packer held 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 parcel and Take next task gated on orders:write and 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.ts is 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/:index fetches 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 writes i.src VERBATIM from the shop’s response, so a hostile shop chooses part of that set. Hence isProductImageUrlAllowed: 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 NOT isUrlSsrfSafe, 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.ts fails the build if a bench route path ever gains a close/commit/finish/seal verb, 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 /shipments and GET /users/packers admit viewer, 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 resolve assignedToUserId to a name and the projection is minimal. scripts/check-bench-write-roles.mjs checks the permission against the union of the controller’s @Roles and against GET /users/packers; the per-route lists are pinned by fulfillment-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 /orders as a separate, non-partitioning field, and the pack bench never moves FulfillmentWork.status (#3482): handledInOpenLinker { stage, workCount } on the orders list and detail reads is derived from the order’s live works by the pure stageOfFulfillmentWork / summarizeOrderWork (fulfillment), one batched IFulfillmentWorkQueryService.summarizeWorkForOrders per page. Four properties are load-bearing. (1) A bench-closed parcel is packed by parcelClosedAt, never by status: the bench records its progress in its own columns (parcelClosedAt, completedAt, packedByUserId) and a shut parcel keeps status = 'open', so a rule reading status alone 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.ts therefore 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, incomplete and a status outside the union take no part (the last returns null, never undefined, because status is a plain text column cast at the repository boundary and undefined would pass the !== null filter and inflate workCount). (4) It is NOT a health bucket (D22): the KPI cards still sum to “All orders”. OrderWorkStageValues is mirrored in apps/web and held identical, in value and order and with one copy entry per stage, by scripts/check-handled-in-openlinker-mirror.mjs under pnpm check:invariants (which also holds WaitingFulfillmentBlockReasonValues); an unknown stage renders nothing, which was safe but silent before the guard. The “Waiting: …” badge reads the persisted fulfillmentBlockReason; the frontend types it with mirrors of FulfillmentBlockReasonValues and FulfillmentRoutingSkipReasonValues, held identical to core by scripts/check-fulfillment-reason-mirror.mjs, which also refuses a WAITING_REASON_COPY key that is not a block reason. Part 2 (#3799) adds four things. (5) The health partition moves a shipped handled order to synced: such an order has no syncStatus rows, so before this it stayed the residual awaiting_dispatch for ever, even after delivery. It now stays awaiting_dispatch while it is on the bench and counts as synced once fulfillmentState is dispatched / delivered, in both twins (FE deriveOrderHealth, SQL IS_SYNCED_HEALTH, which feeds countByHealth, applyHealthFilter and HEALTH_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-effort handledInOpenLinker field, so when that read fails a row can say “Awaiting dispatch” while the card counts it synced. The SQL twin reads fulfillmentState through COALESCE(..., 'not-shipped'): the column is NULL until the first shipment mutation - exactly the state of an order on the bench - and a bare IN (...) made TRUE AND NULL NULL, so such an order counted in neither synced nor (through NOT (...)) 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 through deriveOrderHealth, and check-handled-in-openlinker-mirror.mjs holds the two tables to the same rows. (6) Two ADR-036 raw reads from orders into fulfillment_works (IS_HANDLED_IN_OPENLINKER, IS_ON_PACK_BENCH): read-only, parameter-free EXISTS subqueries on orderId (served by IDX_fulfillment_works_grouping), justified by the health partition and the on-bench filter/count. Their status lists are built from PackingFlowFulfillmentWorkStatusValues / UnpackedFulfillmentWorkStatusValues (fulfillment), the same lists stageOfFulfillmentWork is 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 beside health, never buckets: ?waitingInOpenLinker (the persisted fulfillmentBlockReason in WaitingFulfillmentBlockReasonValues, oldest age from fulfillmentBlockedAt, 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-voided fulfillment_work_verifications row on an open box reads packing, and assignment / assignedToUserId report who packs it (mixed for 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.yml is 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.


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

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

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
// ...
}
}

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.

CapabilityMethod(s)Notes
OrderFulfillmentUpdaterupdateFulfillment({ externalOrderId, status, trackingNumber? })Push a post-create status + tracking update to the destination order (#837). PrestaShop maps the neutral OrderStatus to its native state.
OrderStatusWritebackwrite(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.trackingNumber is data; Shipment.waybillRelayedAt is 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.unsupportedReason splits unsupported into structural no-capability (nothing to retry) and transient adapter-unresolved (disabled connection, credential failure — retryable). Without it a source mid-re-auth was recorded as “nothing to notify”, which also let ShipmentDispatchNotificationService advance a shipment past its at-most-once gate; that path now leaves it retriable. Deliberately a separate field, not new OrderWritebackOutcome values, so a new value cannot fall silently into an existing default: 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

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).

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.

CapabilityMethod
OfferListerlistOffers(input)
OfferEventReaderlistOfferEvents(input)
OfferQuantityBatchUpdaterupdateOfferQuantitiesBatch(cmd)
OfferFieldUpdaterupdateOfferFields(cmd), getDescriptionFormat() (optional, ADR-046)
OfferStockRestorerrestoreStockOnCancellation(targets)
CategoryBrowserfetchCategories(parentId?)
CategoryBarcodeMatchermatchCategoryByBarcode(barcode)
OfferCreatorcreateOffer(cmd)
OfferStatusReadergetOfferStatus(externalOfferId)
OfferSmartClassificationReadergetOfferSmartClassification(externalOfferId)
SellerPoliciesReaderfetchSellerPolicies()
CatalogProductReaderfindProductsByBarcode(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.

  • PricingAuthorityPort: Manages pricing rules and catalog pricing. A capability-port-shaped PricingAuthorityPort is 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 the stockSafetyBuffer config-coercion precedent (#1844) — that resolve a destination price from the master catalog price via a per-connection Connection.config.pricingRule ({ type: 'passthrough' | 'markup' | 'margin', percent?, rounding?: 'none' | 'nearestWhole' | 'endingIn99' }, JSONB — no schema/migration change). markup is cost-plus (price = base * (1 + percent/100)); margin solves for the price whose margin over the base equals percent (price = base / (1 - percent/100), guarded for percent >= 100). Both OfferBuilderService and ProductPublishBuilderService call it as the sole source of command.price whenever no explicit per-item input.price is supplied (an explicit price always wins and is never re-priced) — replacing the prior raw product.price passthrough. 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) renders config.pricingRule alongside config.stockSafetyBuffer (#1844) for every connection, in the shape RateLimitSection (#1810) established — and because apps/web cannot import @openlinker/core (#591), the section’s worked examples run a browser-side mirror of these helpers held identical by pnpm 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. McpToolRegistryService registers a tool iff listCapabilityAdapters({ capability, lazy: true }) finds a supporting-and-enabled connection, but the tools read OL’s own store through IProductsService / 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 of OL_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.ts file 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, so list_connections never emits credentialsRef/config and get_order never 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. McpToolDefinition carries requiredScope + requiresAdmin (both required — a write tool that forgot requiresAdmin would otherwise default to unprivileged). McpToolRegistryService checks at registration and at call time before the rate-limiter acquires, so a refusal costs no budget and audits as outcome: '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 is resolve_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 keeps browse_categories deferred 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. OfferBuilderService and ProductPublishBuilderService each know their destination kind statically; an MCP tool does not, because the agent supplies an arbitrary connectionId from list_connections. resolveDestinationContext (apps/api/src/mcp/tools/read/destination-context.ts) probes OfferManager then ProductPublisher to resolve two facts the core services cannot derive themselves: which capability exposes the live category schema (a shop serves it under ProductPublisher and does not support OfferManager — projecting a shop under the default would throw), and whether the destination borrows a taxonomy (#1045), without which resolve_category reports manual for an Erli connection whose operator already has a working Allegro-authored mapping.
  • Two traps the write path had to avoid. upsert_category_mapping requires sourceConnectionId even though CategoryMappingInput marks it optional (#1036 record-only): the repository upsert matches that column with IsNull() semantics while findBySourceCategory is oldest-wins, so an omitted value inserts a duplicate row and the write reports success while changing nothing. And the audit wrapper now reads destinationConnectionId as well as connectionId, 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.


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.

  1. Generate Internal Identifiers: Creates new unique internal IDs for entities when they are first encountered from external platforms
  2. Map External to Internal: Provides mapping from external platform IDs to internal OpenLinker IDs
  3. Context-Aware Mapping: Handles mapping based on entity type (Product, Order, Offer, etc.), platform, and context
  4. Maintain Mapping Registry: Stores and retrieves mappings between external and internal identifiers

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 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 identifiers are generated from a single unified seed across all entity types:

  • Format: ol_{prefix}_{uuid} where prefix defaults to entityType.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 TEXT type in PostgreSQL (not UUID)
  • Prefix overrides: A small ENTITY_TYPE_ID_PREFIX map in identifier-mapping.types.ts overrides the default for entity types where the documented prefix diverges from the lowercased class name. Today: ProductVariant → variant (so IDs are ol_variant_*, not ol_productvariant_*).
  • Canonical Entities: Product, ProductVariant, InventoryItem use internal IDs as primary keys

Adapters are responsible for:

  1. Fetching data from external platforms
  2. Transforming data to OpenLinker unified schema
  3. 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);
}
}

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 platformType for faster lookups
  • Data integrity: platformType is immutable on Connection, safe to denormalize
  1. Unified Identity: All entities have consistent internal identifiers regardless of source
  2. Platform Agnostic: Core domain logic works with internal IDs only
  3. Traceability: Can always find external IDs from internal IDs and vice versa
  4. Adapter Responsibility: Adapters handle ID translation, keeping core domain clean
  5. Single Source of Truth: One service manages all identifier mappings

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.

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)

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:

  1. PrestaShop adapter provisions a guest customer (is_guest=1)
  2. Customer is created with valid password (5-72 chars, PrestaShop hashes internally)
  3. Customer ID is stored in identifier mappings for future reuse

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: emailHash is always persisted regardless of PII setting

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

Addresses are reused across orders when identical (determined by hash):

  • Hash Components: address1, address2, city, postcode, countryIso2
  • Reuse Priority:
    1. Primary: Query destination_address_mappings table (fast, deterministic)
    2. Fallback: Query PrestaShop addresses and match by hash (recovery scenario)
  • Address Alias: Deterministic alias format: OL-{type}-{hash-prefix} (e.g., OL-shipping-a1b2c3)

When emailHash matches multiple customers (collision):

  • Policy: Create new internal customer (no merge)
  • Logging: Warning logged with emailHash and match count
  • Result: collisionDetected=true in resolution result
  • Rationale: Prevents incorrect customer merging (shared emails in families/businesses)

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.ts
interfaces → application → domain
↓ ↓
infrastructure → domain

Rules:

  • 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

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:

  1. 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
    }
  2. Implement port in infrastructure layer:

    infrastructure/persistence/repositories/product.repository.ts
    @Injectable()
    export class ProductRepository implements ProductRepositoryPort {
    // Implementation using TypeORM
    }
  3. 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
    ) {}
    }
  4. 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 directly
import { ProductRepository } from '../infrastructure/persistence/repositories/product.repository';
// Service works with ORM entities
const 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 checking
export interface ProductRepositoryPort {
insertMapping(...): Promise<...>;
isUniqueViolationError(error: unknown): boolean; // ❌ Infrastructure-specific
}
// Service depends on infrastructure error types
catch (error) {
if (error instanceof QueryFailedError) { // ❌ Infrastructure awareness
// ...
}
}

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.

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:

AllowedPatternExample
Service interfacesI*ServiceIIntegrationsService, IIdentifierMappingService
DI tokens (Symbol)*_TOKENINTEGRATIONS_SERVICE_TOKEN, EVENT_PUBLISHER_TOKEN
Capability ports*Port (single Port suffix)OfferManagerPort, OrderSourcePort, EventPublisherPort
Capability type-guardsis*isOfferCreator, isCategoryBarcodeMatcher
Domain entities, value objects, type aliasespublished in the barrelConnection, Product, Order, MarketplaceCursor
Domain exceptions*Exception, *ErrorConnectionNotFoundException, DuplicateIdentifierMappingError
Other as const value constantsUPPER_SNAKE_CASECORE_ENTITY_TYPE, OFFER_CREATION_STATUS
NestJS module classes*Module — for imports: [...] only, never injected into servicesCustomersModule in orders.module.ts

The cross-context contract is explicitly forbidden for these symbol shapes:

ForbiddenPatternWhy
Repository ports*RepositoryPortIntra-context contract — exposes persistence concerns the source context controls. Cross-context callers go through I*Service.
ORM entities*OrmEntityTypeORM-decorated infrastructure detail (and ESLint-guarded under @openlinker/core/<ctx>/orm-entities separately).
Adapter classes*AdapterConcrete infrastructure; sibling contexts see behaviour through I*Service or capability ports, never the adapter directly.
Application DTOs*DtoOwned by the source context’s interface layer.
Default importsimport X from '@openlinker/core/<ctx>'Barrels have no default export.
Namespace importsimport * 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.

  • Service interfaces are the seam. A context’s I*Service shape is the only thing sibling contexts can rely on staying stable. When products reorganises its repository layout, the consumers in orders / listings / inventory don’t break — they kept asking IProductsService for what they needed, and products is 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 orders imports Product from @openlinker/core/products, it binds to the published shape — products can 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.ts imports CustomersModule to compose providers. A service constructor must never type-hint CustomersModule directly — that’s the wrong layer.

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-authority

identifier-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.

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, and apps/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 under libs/integrations/ (ADR-055 — the integrations-* 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 it kind: 'product' and, exactly like a plugin, never applies the same-context skip.
  • apps/{api,worker}/** — host apps, including src/** and test/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.


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.json

The 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 OrderProcessorManager connections)
  • ✅ 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 OrderProcessorManager connections (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.


1. Order Synchronization Flow (Marketplace → Shop)

Section titled “1. Order Synchronization Flow (Marketplace → Shop)”
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 status
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 API

2. 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.
External System Event
│
▼
Adapter (e.g., AllegroAdapter)
│
│ Emits domain event
▼
Event Bus (Redis Streams)
│
▼
Event Handlers
│
├─> OrderSyncListener
├─> InventorySyncListener
└─> NotificationListener

4. 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_jobs row commits in the SAME Postgres transaction as the webhook_deliveries gate row, so “delivery recorded” and “job exists” can never diverge. Redis is entirely out of the durable path at ingress — the jobs.sync stream, the jobdedup:* reservation, and the events.inbound.webhooks publish are all gone from this flow; the runner’s existing 1 s poll is the wake-up. The pre-#2280 poison gap (webhook marked published, 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: InboundWebhookRoutingService classifies deterministic faults (ConnectionNotFound/ConnectionDisabled, no translator for the adapterKey, undecodable event, capability-ungated domain) as unroutable → a durable deadlettered delivery row with dlqReason, 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 from route() 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): LegacyInboundWebhookDrain was the one-shot boot pass that consumed the retired webhook-handler group’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 with events.inbound.webhooks and events.inbound.webhooks.dead, which by then had no writer (#2280 removed the publisher) and no reader. A name in REDIS_STREAM_NAMES with 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 the DEL that reclaims both keys are in docs/operations/redis-stream-retention.md.
  • Every stream carries a declared retention bound (#2163, ADR-049): XACK removes 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 inside RedisStreamsEventPublisher, which three of the five write sites never call (jobs.sync and 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 in libs/core and 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 than undefined, so “unbounded” is no longer a reachable state. And the bound is enforced at the call site: xAddBounded types its streamName as the RedisStreamName union, and scripts/check-stream-writes.mjs (in check: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 is xAddBoundedDynamic, used only by RedisStreamsEventPublisher because EventPublisherPort.publish takes a dynamic stream name by contract. Two streams are bounded by AGE (MINID), not count, and the distinction is a correctness one. jobs.sync because until job-intake writes the sync_jobs row the stream entry is the job, and RedisStreamsJobEnqueueService sets jobdedup:{key} with a 7-day TTL before the XADD - 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 while webhook_deliveries still reads job_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 durable webhook_deliveries gate 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 to sync_jobs at ingress and never transits jobs.sync or jobdedup:* at all, so the trim-vs-TTL interplay above still matters only for the remaining non-webhook writers of that stream. events.master.deletion.dead because 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_letters receives an entry only after it has failed recovery MAX_RECOVERY_ATTEMPTS times (a handler that keeps throwing), whereas a malformed event is classified on its first read, written here and XACKed 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 to stream_dead_letters as well, the reason would reduce to that window and should be restated here. (The contrast was originally drawn against events.inbound.webhooks.dead, whose dead-lettering was durable in webhook_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-time XTRIM - and, because noeviction refuses the very XADD that would trim, a Redis that boots already above maxmemory cannot leave that state on its own (XTRIM is not a denyoom command 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 why healthcheck uses an exact MAXLEN 1 - ~ 1 would really retain ~100 entries. Redis itself is now started with an explicit --maxmemory and --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), and noeviction is deliberate because under any allkeys-* policy Redis can evict a whole stream key with no error surfaced to any consumer. events.sync.jobs was 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 nothing sync_jobs did 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 (XREADGROUP always used id: '>', 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 the webhook_deliveries row still read published, making a dropped order indistinguishable from a delivered one. Three shared primitives in @openlinker/shared/redis now close this for all three consumer groups (webhook-handler, master-deletion-offer-pause, job-intake): a stable consumer identity (resolveConsumerName → OL_WORKER_ID or hostname, never process.pid — in a container PID is typically 1, 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 own XPENDING history 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, via XPENDING ... 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 the XPENDING listing, decides what gets processed: an XCLAIM that 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 distinct trimmed outcome and ACKed to clear the dangling id — never routed into the handler’s error path, which would persist a bogus dead sync_jobs row or a dead-letter entry describing an event that was never actually dropped. Neither half uses XREADGROUP to recover, because node-redis (v1.5.x) transforms its reply through transformTuplesReply, which calls .length on the field array and therefore throws on a trimmed entry — aborting the drain and leaving the dangling id unackable. XPENDING + XRANGE return 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. The webhook-handler group 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-pause and job-intake, and the primitives stay exactly as load-bearing for them: both use ackTrimmed and resolveConsumerName directly, and toPendingRows — whose last external caller went with the drain — remains the internal engine of readOwnPending and reclaimOrphans, 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 the sync_jobs unique 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.edit covers a product edit and a panel stock edit, so it yields master.product.syncByExternalId plus master.inventory.syncByExternalId (the second only where InventoryMaster is 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 any webhook_deliveries row is written (this doc’s ADR-005 dedup-gate invariant), so that table cannot record failures. WebhookService instead upserts a durable rolling counter to a distinct webhook_auth_rejections table (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-facing activation: '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 to not-registered.
  • webhook_deliveries.status is monotonic (#1916, narrowed by #2280): a NEW webhook row is written once, in its final status, inside the gate transaction — the received → published → job_enqueued ladder collapses to a single statement and published is unreachable for new rows. The WebhookDeliveryRepository.upsert rank guard (WEBHOOK_DELIVERY_STATUS_RANK: received < published < job_enqueued < the attention-worthy terminals rejected/failed/deadlettered) is retained: it was what let the drain advance a pre-#2280 legacy row stuck at published after 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): InboundWebhookRoutingService resolves the connection’s per-plugin WebhookEventTranslator (translate → neutral CanonicalInboundEvent) and delegates to the capability-gated core InboundRoutingPolicy.resolve(), which maps domain → 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 to marketplace.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 the date_upd watermark. Both paths converge on the idempotent OrderIngestionService.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_key unique 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.


  • Framework: NestJS
  • Language: TypeScript (strict mode)
  • Database: PostgreSQL (TypeORM)
  • Caching: Redis
  • Event Bus: Redis Streams (initial), RabbitMQ/Kafka (future)
  • Package Manager: pnpm (monorepo)
  • 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
  • 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 via installNestLogger() from @openlinker/shared/logging/nest
  • Linting: ESLint
  • Formatting: Prettier
  • Testing: Jest
  • Type Checking: TypeScript (strict mode)