39 min readOct 11, 2026by jakub

Changelog

Current version: 1.0.0

All notable changes to ContentBridge are documented in this file.

The format follows Keep a Changelog.

[Unreleased]

[1.0.0] - 2026-10-10

Added

  • Composer suite metapackage. qoliber/content-bridge-metapackage installs Core, Sender and Receiver at the same release version. The separate packages are qoliber/content-bridge, qoliber/content-bridge-sender and qoliber/content-bridge-receiver. Packages include the Qoliber extension license and copyright/license headers in source, templates and configuration.

  • The merge says which new product images it left out. With Send Product Images off — the default — an image that is new on the receiving store cannot be created, and it was left out silently: the only trace was a line in the receiver's log, and a promoted catalog went live without its new images. The dry run and the commit now list each product and the images left out, in the Merge Wizard and on the direct trigger (the first ten, then a count), with what to do: turn the setting on and merge those products again, unless the receiving store already shows those pictures under other file names. A row that failed anyway is reported by its error alone. The admin help text for Send Product Images and its size limit said the opposite — that gallery entries always land and the images appear once the media is copied across — and now says what happens.

  • Outbound Allowed Hosts (optional). A comma-separated list of hosts this instance may send to, empty by default. Deliberately an allow-list an operator opts into rather than a blocklist of private ranges: the primary deployment is staging to production, very often over an internal network, so blocking RFC1918 by default would refuse the ordinary case — and a protection operators must switch off to do their job is one they switch off. When set, a request to any other host is refused before the HTTP client is even configured. A URL whose host cannot be parsed is refused too, because "cannot be checked" must not read as "allowed". The check rides inside the same call that applies TLS, so a future outbound client cannot apply one and forget the other.

  • Categories under a second store root are merge-able. A category's natural key is now <root>/<path> rather than a bare root-relative path. A Magento install has one root category per store GROUP, and this module already merges websites, store groups and store views — so a second root is an ordinary configuration here. Anchored to a single root, every category under any other root produced no path key at all and was absent from the inventory, the preview and the merge; nothing was ever wrong, half the structure simply never moved and nothing said so. The root is identified by its NAME, which is already how a root travels (StoreGroupMergeProvider carries a group's root as root_category_name), so a root has one portable identity rather than two that could drift. A top-level category now lands under the root its own key names instead of under whichever root is the default, and a key naming a root this instance lacks fails the row. Two roots whose names normalize identically are both dropped, matching the existing collision rule for category keys.

  • Customer groups carry a portable tax-class key. A group's tax class travels as the same typed natural key the merge engine uses (CUSTOMER:<name>) instead of a raw tax_class_id with a hardcoded fallback of 3. Magento's default id 3 is an installation accident — a receiver can assign it to a different class — so an unknown or wrong-typed class now fails that row naming the reason rather than silently taxing customers under the wrong class. This changes the payload shape: a sender older than the receiver sends tax_class_id and has every customer-group row refused, so upgrade the receiver first and run matching versions on both instances.

  • Entity references embedded in content are translated. A {{widget}}/{{block}} directive addressing another entity by raw id resolves by primary key, so shipping it verbatim rendered — or linked to — whichever entity owned that number on the receiver. The page still loaded, which is what made it dangerous. Four kinds now travel as a portable key and are rewritten to this instance's own id: CMS blocks (block_id) and pages (page_id) by identifier, products (id_path="product/12") by SKU, and categories (id_path="category/37") by root-qualified <root>/<url_key path> — the same natural keys their own merge providers match on, computed by the same collaborators so a link and its target cannot drift apart. Page Builder, which emits the numeric form and whose stage filters on the numeric column, keeps working on both sides. An identifier-form directive is already portable and is left exactly as written; a reference that cannot be repointed fails the row naming it (unresolved_content_reference). This runs on BOTH paths now: the CMS push path, and the merge path — email, newsletter and Page Builder templates, category descriptions, and product descriptions, short descriptions and custom WYSIWYG attributes. On the merge path the row is translated BEFORE change detection sees it, so a body that differs only by the ids being local is not reported as a change and does not re-save on every run, and before the row is CREATED, so a refused reference leaves nothing behind. Each kind is a small registered class, so adding one is not a change to the translator. The wire shape is directive_references keyed by parameter.

  • Profile runs and staged pushes are guarded server-side. Both now go through the same outbound-connection guard the merge endpoints use, so a directly-POSTed connection_id cannot reach the push path using a receiver-role, disabled, revoked or expired connection.

Fixed

  • A website-scoped tier price no longer fails its product on every merge after the first. On a store whose Catalog Price Scope is Global, Magento loads only all-websites tier prices, so a website-scoped price a merge had written was invisible to the next merge, inserted again and refused by the table's unique key. Such a price is now refused when it arrives, naming the setting to change; it is never widened to all websites. Stores that price per website merge it as before.

  • Generate Pairing Code appears only where the pairing page exists. The Connections grid offered the button on every store, but the page it opens belongs to the Receiver module, so on a store running the core and the Sender alone it led to a 404.

  • A merge sent in parts applies each row only to the target its own resolve phase chose. The apply phase read targets from the identity map at apply time, so a concurrent merge that remapped a row in between redirected this merge's content onto the other merge's target. A row whose mapping changed since is now refused, naming why.

  • Two merges on one connection can no longer interleave. A row's own target was protected, but not what it depends on: merge A resolved a category, merge B remapped it, and A's product was filed under B's category while A's own category row was rightly refused. The receiver now commits one request at a time per connection, and every commit request returns a sequence the next request of the same merge must present. When another merge committed in between, the rest of the merge is refused before anything in it is applied, saying so — a fresh dry run and commit completes it consistently.

  • Only a valid receiver answer marks pushed content as synced. Any HTTP 2xx counted as success, and a body without per-item detail was read as "the whole batch applied" — a maintenance page answering 200 marked content synced that never arrived, and nothing would send it again. A push now succeeds only when the receiver's own JSON names what it applied.

  • An item whose own image the receiver refused is reported failed, naming the image, instead of applied — so it is retried rather than recorded synced around a missing or stale image.

  • Staging no longer pins a profile. A staged item restricts the next "Sync now" to the staged items, and was released only when it synced — so one staged while unchanged, or deleted after staging, was never released, and every later run silently skipped all other changed content. An item with nothing to send is now released by the run that finds so; one the receiver refuses stays staged and is retried alone, its failure reported each time. Re-staging an already-synced item stages it again, where it was a silent "Added 0".

  • A force-push records the hash of the content it sent, taken before exporting, rather than of an edit saved during the request.

  • Incoming gallery images and downloadable files are checked against the receiver's own size limits, by encoded length before decoding and by decoded size, with at most 256 MB of files per merge request.

  • Audit rows from unauthenticated requests are bounded and expire. At most 30 rejected requests (and 30 failed pairing attempts) per minute are recorded in full, then a single notice; those rows are purged after 30 days. Rate-limit the public endpoints at the web server — see OPERATIONS.md. The count is taken in one atomic statement — a read-then-write count let a delayed request reset an exhausted one, and a failed write counted nothing — and a count that cannot be taken records nothing. Failures for a known connection have their own allowance, so a flood of invented fingerprints cannot hide an attack on a real connection or its secret_unavailable diagnostic.

  • A profile run whose request to the receiver failed is reported as failed. A 413, a 500 or a maintenance page carries no per-item failures, and only those were counted, so such a run showed a green "0 pushed" and was audited as synced. It now shows an error naming the HTTP status per provider, and is audited as failed (partial when only some items failed).

  • Nothing that cannot be encoded is sent. A row carrying invalid UTF-8 failed to encode, and the failure became an empty body that was signed and sent; the receiver read it as a dry run of nothing and answered ok, so a commit reported success with nothing applied. The sender now refuses to send it, naming why, and the receiver refuses a signed body that is not a merge request.

  • The Merge Wizard says when a connection's secret cannot be decrypted. Since the empty-secret fix, the probe and dry run answered that case with a server error the wizard could only call "unable to reach the receiver"; it now shows the instruction to re-pair the connection.

  • The dry run previews a product's whole change. It compared the product's own fields alone, so a change only the write pass makes — a link, a tier price, a custom option, a gallery image, a variant, a website, an option value — previewed as "matched" and was then written by the commit. The preview now runs the write pass itself on a copy of the product it never saves; images and downloadable files are written only by the save, so it leaves nothing behind. Price rules and widgets are still judged by their own fields, which the wizard says beneath the counts.

  • A dry run with a failing row leaves the database connection as it found it. A row that failed inside a transaction it had opened — an \Error from a plugin or observer, which Magento's resource models do not roll back — kept the dry run's own transaction open after the preview, because Magento's rollback inside a nested transaction only counts down. Whatever the request wrote next, its audit row among them, went into a transaction nothing would commit. The dry run now rolls back to the level it started from.

  • A price rule or widget renamed on the sender is renamed here, not duplicated. Catalog price rules, cart price rules and widgets were matched by name alone, so a rename created a second rule beside the one merged before — two discounts where there had been one — and a manual mapping was forgotten by the next run. Each is now found first by the sender's own id, as this connection last merged it; matching by name remains for one never merged here.

  • Content linking to an entity its own merge creates is complete in one run. A product description linking to another new product of the merge, or a category description linking to a new product, was refused because the target did not exist yet when the content was translated — the merge needed a second run. A reference to an entity the merge is creating now waits for it: the row is written without that content, and the content is written in the second pass, once the target exists. When the target travels in a later request — a merge sent in parts, or a later type — the row is reported as waiting and the sender sends it again once the whole merge has run; if the target was never created after all, the waiting row fails naming why. A reference to anything outside the merge is still refused, and a waiting row keeps the content it had until the new content can be written.

  • A commit that waited for another to finish reads current websites and stores. Two simultaneous requests on one connection are committed one at a time, but the second had read the websites, store groups and store views before it waited, so it did not find a website the first had just created and failed trying to create it again. Scope data is now read afresh once a commit holds the connection's lock. Found by an e2e spec sending real concurrent requests.

  • A commit whose answer was lost says the receiver may have applied it. A timeout or dropped connection escaped as "Merge failed", and an operator reading "failed" would assume nothing was written. The sender now says it cannot tell whether the receiver applied the request, and that a dry run shows where it stands — committing again matches what was created rather than duplicating it. Recovery after a lost answer, and after a commit stopped between its resolve and apply phases, is covered by tests against the receiver's endpoint; real simultaneous requests on one connection by an e2e spec.

  • A merge can remove a link to a product the receiver's storefront cannot see, and stops re-saving products that have one. The receiver merges in a storefront request, where a product's links read back without the linked products that are not on that store's website — and Magento's own link save deletes old links through that same filtered list. So a link to such a product could never be removed by a merge, and a product whose links pointed off-site looked changed and was saved again on every merge. The current links are now read from the link tables, and a link the merge removes is deleted by its row.

  • A product's removed links and cleared options reach the receiver — but only what a merge put there. Removing a product's last link of a type, or clearing a select, never carried over; and removing one up-sell while others remained took the receiver's own up-sells of that type with it, since a mentioned type was replaced wholesale. The receiver now records what each connection's merges wrote (a new table, qoliber_contentbridge_merged_state, so run setup:upgrade), and a merge removes a link or option value only when this connection wrote it and the sender has dropped it; links and values the receiver holds of its own stay, and an attribute the row does not mention is left alone. The sender now sends a product's empty select/multiselect attributes as empty lists so the receiver can tell "cleared" from "not mentioned" — an older receiver ignores them. The dry run lists what a commit would remove, in the wizard and on the direct trigger. The record starts with the first merge after upgrading: a removal made on the sender before that merge is not carried.

  • A product's content is checked even when the sender described no reference in it. A row carrying no reference metadata skipped translation, but the sender describes only what it can — a link to an entity deleted there gets no entry — so that raw sender id was written into the description as it was, linking to whatever owns that number here. It now fails the row, naming the reference, as it already did for CMS content. And when every custom attribute of a product waited on an entity its merge was still creating, the attributes were written with their original, untranslated values; they now keep what they held until they can be written.

  • A CMS block for another store view is no longer refused. Only stores was set, so the repository filled store_id with the receiver's current storefront view, and Magento's uniqueness check tested the block against that view: pushing footer for store B failed whenever the receiver had its own footer for the default view.

  • Pushing one CMS block or page pushes that one. A grid row or the form's push button resolved to the bare identifier, which exports every block sharing it, across store views the admin never opened. Each row now names its own sibling, for a force-push and a staged run alike.

  • A CMS item for a store view the receiver lacks is reported, not re-sent forever in silence. It was applied to the store views that exist and reported under the resolved scope — an id the sender never sent — so it was never recorded synced and went out again on every run without a word. It is now reported under the sender's own id as incomplete, naming the missing store views; its images are still written, and it completes once those views exist.

  • A push cannot disable a store group's default view. Magento's admin refuses that — it takes the storefront down — but a resource save does not ask; the row now fails instead.

  • "Select All" in a grid's ContentBridge push action selects the grid's rows. The mass action sends no ids then — only "everything the grid shows, except these" — and the picker read only ticked ids, so selecting everything pushed nothing. It is resolved through Magento's own mass-action filter, against the grid's collection as filtered (CMS blocks, CMS pages, customer groups).

  • The sync profile's Delete button deletes. It navigated to the POST-only action, which answered 404.

  • The Sync Profiles grid can be filtered by profile or connection name. Either filter was an SQL error.

  • Admin pages highlight their menu item. Five pages named menu ids that do not exist.

  • A stale form for a deleted profile is refused instead of creating a new profile while reporting the old one updated, and a profile's coverage and "Add to profile" accept only entity types this instance can push; anything else was kept and skipped by every run without a word.

  • URL rewrites: a manual mapping is honoured, and a mapped store view finds its rewrites again. A manual mapping onto another hand-made rewrite was ignored — the row was written by its request path, beside the mapped rewrite — and an autogenerated rewrite was accepted as a mapping target. A rewrite in a store view mapped onto one with another code was written but looked up again by the sender's code, so a phased commit refused it as gone. A dry run of a store view and its rewrites together previews the rewrites again instead of failing each one.

  • An item whose own image the receiver would refuse is no longer sent, for good, every run. A file over the receiver's size cap, or whose bytes do not match its extension, was sent, refused on arrival, and its item failed and re-imported on every run. The sender now applies the same checks, holds the item back, and reports it failed naming the file and the reason.

  • A partly refused force-push says what arrived and why the rest did not, instead of "Push failed (HTTP 200)".

  • A row that fails inside a transaction no longer takes the rest of the merge with it. Magento's resource models roll back on \Exception only, so an \Error — a TypeError in a plugin or observer — escaped a save with its transaction still open. Every later row of the merge was written inside it, never committed, and lost when the request ended, while the report said it was applied. The engine now rolls back whatever a failed row left open.

  • A new product can use an option its own merge adds. A matched attribute's new options were added in its second pass, after every product of the same merge had been created: a required select failed the product, and an optional one arrived a pass late. They are added when the attribute is matched now.

  • Grouped products and bundles with dynamic price or weight are no longer saved on every merge. Export turned their absent price or weight into 0.0, which never matched the receiver's own null.

  • A new category whose description cannot be repointed is never created. It was created first and refused afterwards, left active and in the menu without its content.

  • Previewing a new website, store group and store view together no longer reports an error. The view looked for a website the dry run had not created; a website that exists but has no usable group is still reported.

  • An expired connection no longer holds a place under the inbound cap. It authenticates nobody, but it kept a receiver at its cap and refused every new pairing until an admin removed it.

  • Connection status changes and deletions are audited, with the status before and after.

  • The pairing endpoint checks the type of every field and the requested lifetime. An array code raised an unhandled error page on a public endpoint, and a lifetime of "abc" became a connection that never expires. Anything but a positive number of seconds ending before 2038 is refused with invalid_duration.

  • A custom connection expiry is read in the store's configured timezone, not as UTC.

  • A failed part of a merge says what was applied. A part failing while its rows were being resolved was reported as "the parts before it were applied", although they had only been matched or created; the message now says what each stage left behind. A resolve answer that does not say where its rows landed stops the commit instead of applying nothing and reporting success.

  • A direct-trigger merge is sent in parts too, a type at a time in dependency order, rather than as one request a stock receiver refuses.

  • Merging a URL rewrite no longer deletes the receiver's other rewrites. Rewrites were written through UrlPersistInterface::replace(), which first deletes every row sharing the new row's store, entity type and entity id. For a custom rewrite that was every OTHER custom rewrite in the store, the receiver's own included; for a hand-made product or category redirect it was that entity's canonical SEO URL. Each rewrite is now saved as exactly one row, updated in place when a hand-made one already holds its path, and a path owned by an autogenerated URL fails the row instead of taking it.

  • A connection whose secret cannot be decrypted authenticates nobody. Magento's encryptor returns an empty string for a missing key version or a foreign key, and that empty string was used as the HMAC key, so a request signed with an empty key authenticated. A staging database refreshed from a production dump is exactly that case. Decryption to nothing is now an error, the signer never accepts or produces an empty-key signature, and the receiver audits it as secret_unavailable while answering like any bad signature.

  • New products are created with their attribute values, and bundles can be created at all. A new product was saved as a five-field skeleton, which Magento refuses when its attribute set has a required attribute, and a bundle's price-mode flags never travelled, so no new bundle could be created. A product is now born with every value an update would write, and a type's behaviour attributes travel (price_type, sku_type, weight_type, price_view, shipment_type for bundles; links_purchased_separately, links_title, samples_title for downloadables). A bundle switched to fixed pricing on the sender now follows on the receiver, and the preview says so.

  • Product select values match by admin label, whatever store the request runs in. Labels were read in the current store's language, and the receiver applies merges inside a store view, so a translated option was not matched: select values were silently dropped, multiselects partly overwritten, and configurables refused.

  • Merges are sent in parts a stock receiver accepts. A whole entity type travelled as one request, which nginx's default 1 MB body limit refuses for a few hundred products. Rows now travel in parts of about 700 KB. A commit spanning several parts resolves every part before applying any, so references between parts (variants, bundle selections, links) still resolve, and where each row landed is read from the receiver's own identity map. A part refused with HTTP 413 names the receiver setting to raise, and a receiver too old to understand parts stops the commit rather than running parts twice.

  • A store view created on the receiver gets its URL rewrites. Magento builds them in plugins it registers for the admin area only, and the receiver applies merges and pushes on a storefront route. They are now registered for that area too, so a merged or pushed store view no longer arrives with every SEO URL 404ing.

  • Page Builder images travel. The media directive was recognised only with its path quoted, and Page Builder writes it unquoted, both for an image's src and inside background-image JSON.

  • Pushed configuration is saved through each field's backend model. Values went straight into core_config_data, skipping validation and side effects. A pushed URL suffix therefore left every existing rewrite on the old suffix, and every catalog URL 404'd. Values are now saved the way the admin and config:set save them.

  • A receiver no longer describes its own internals to a sender. The signed endpoints answered an unexpected failure with the exception message — the pairing leak one layer in: behind authentication, but still across an INSTANCE boundary, and staging and production are routinely run by different teams. Ordinary per-row failures are untouched (those are not exceptions; they travel in the successful response and stay precise). Only genuinely unexpected errors are affected, and they now return a generic code plus a short REFERENCE that is also recorded on the receiver — so an operator holding the receiver's log can correlate the two without the receiver disclosing anything about itself.

  • Product gallery images are verified on arrival. Of the four inline transfers this module makes, product photos — the most numerous — were the only one with no integrity check; media transfer, swatches and downloadable files all verified a digest. A truncated or altered payload was written into the receiver's media and shown on the storefront as a successful merge. The digest now travels with the bytes and is MANDATORY, on the same reasoning the swatch path already documented: an optional check is none, because a sender omitting the hash simply skips it — exactly what something substituting bytes would do. A mismatch fails the row rather than dropping the image quietly: an image that never arrives 404s visibly, while a corrupted one overwriting a good file looks fine until a customer sees it.

  • Failed inbound authentication is audited. Every successful merge was recorded in detail and not one REJECTED request was, so a brute-force, replay or forged-signature campaign left no trace. Each failure now writes receive/auth_failed with the presented fingerprint and the real reason. Monitoring should alert on a rising rate of it.

  • An inbound request can no longer be used to probe connection state. An unknown fingerprint answered connection_unknown (401), a known-but-expired one connection_expired (403) and a known one a signature reason — so anyone holding a guessed fingerprint could learn whether it existed here and what state it was in without ever signing anything. All pre-authentication failures now return one identical response, and expiry is disclosed only after a valid signature proves the caller owns the connection. The reason moved to the audit log. The gate itself existed three times (merge, sync, probe), which is why one leak appeared three times; it is now a single InboundAuthenticator.

  • The pairing endpoint no longer reflects internal error messages. Pairing is reachable UNAUTHENTICATED — it is the bootstrap — and a 500 returned $e->getMessage(), handing any caller whatever a database driver, a filesystem path or a third-party class had to say. A bare RuntimeException from deeper code was worse: it was caught as a 401 and echoed as though it were a deliberate refusal. The three codes this module raises on purpose (invalid_or_expired_code, pairing_busy, insecure_sender_base_url) are now a typed exception and still returned verbatim; anything else is pairing_error, with the detail logged and audited.

  • A downloadable link's file is verified on arrival. Inline CMS media has always been checked byte-for-byte with a mandatory sha256 compare, while the file a customer pays for and downloads travelled with no integrity check at all — a truncated or altered payload was written and reported as a successful merge. The digest now travels with the bytes and is verified before the file is written; a mismatch refuses the file rather than overwriting a good copy. A payload carrying NO digest is refused too, since a check that can be skipped by omitting a field is not a check.

  • Entity references embedded in store CONFIG values are translated. An allow-listed config value can be HTML an admin edits — Footer Miscellaneous HTML, Scripts and Style Sheets — and Magento renders {{widget block_id="9"}} there, so a promoted value rendered whichever block owned that number on the receiver. Every value is scanned rather than a list of "HTML paths" guessed here: which paths hold HTML is the OPERATOR's choice, since the allow-list is theirs, so an enumeration would miss one by construction. Translation happens before the receiver-side syncable re-check, so the value validated is the value stored; a reference that cannot be repointed fails that path and leaves the receiver's own value untouched.

  • The canonical signing string states its own unambiguity instead of inheriting it from HTTP. The CB1 string joins its fields with \n, so it is only unambiguous while no field contains one — a nonce of "abc\nFP" with an empty fingerprint produces byte-identical output to a nonce of "abc" with fingerprint "FP", and a signature authenticating one would authenticate the other. Today that cannot be reached: the nonce, path and content type arrive as an HTTP request line or headers, which cannot carry a raw newline, and the remaining fields are encoded or hashed. That is a property of the transport, not of this code, and it stops holding the day a request arrives by another route. The builder now refuses any signed field containing a line break or NUL, and the verifier reports it as signature_mismatch — the same reason as any bad signature, so an unauthenticated caller learns nothing about how the receiver parses requests and no nonce is consumed. The wire FORMAT is deliberately unchanged: it is a contract both instances must compute identically, and altering it to close something unreachable would force every pairing to upgrade in lockstep forever.

  • A hand-made CMS-page URL rewrite no longer redirects to whichever page owns the sender's id. Autogenerated cms-page rewrites regenerate on the receiver when the page is pushed, which is why they are never carried — but a HAND-MADE one ("For CMS page" in Marketing > URL Rewrites) does not regenerate, and it travelled with its cms/page/view/page_id/N target intact. The redirect resolved and rendered a page, which is what made it quiet. The page's identifier now travels beside the rewrite and the target is rebuilt from this instance's own id, resolved by the same CmsPageReference that resolves a page referenced from content — so a redirect and the content linking to it can never disagree about which page is meant. A page absent here, or ambiguous across store scopes, fails the row naming it. A CUSTOM rewrite whose admin-typed target embeds a row number is refused with the remedy named, rather than string-patched: rewriting ids inside a path means encoding Magento's routing conventions and getting them wrong the first time one changes.

  • A merged category is written at default scope, not as a store-view override. CategoryRepository::save() takes the store from storeManager->getStore() and overwrites whatever store the category was loaded for, and the receive endpoint is a frontend route — so the current store there is a real store view, and every merged update landed as an override of name, is_active, is_anchor, include_in_menu and description while the default-scope values kept the old text. Everything the provider reads uses store 0, so the change was invisible to the next export and the row previewed as will_update on every subsequent run — re-saving a category that already agreed, which regenerates URL rewrites for the whole tree. The save now runs under default-scope admin emulation, the same way ProductMergeProvider handles its own scope-sensitive write, and the target is loaded at store 0 so the values compared and written are the ones being maintained. Products were never affected: ProductRepository::save() respects the model's own store.

  • A widget's CMS block is resolved by store scope, not by first match. A block identifier is unique only within a store scope, so footer-links can exist once per store with different content. The block's own store assignment now travels with the identifier and the receiver matches on it; an ambiguous or wrong-scoped reference fails the row instead of wiring the widget to another store's content.

  • Media is no longer credited to a row that failed. A failed row is reported under a key derived purely from the payload, so the receiver reproduces it exactly and a failed row can no longer contribute media through a successful same-identifier sibling.

Changed

  • Unused profile mode/status UI and schema fields removed (whitelist entries retained so declarative schema performs the drop).
  • Composer manifests corrected: the canonical authors block, and the supported range stated as PHP 8.3–8.4 across the manifests and the README. That is what Magento 2.4.8 — the oldest version the magento/framework constraint admits — runs on, and the unit and integration suites pass on both; 8.1–8.2 could never install alongside 2.4.8, and 8.5 is not a Magento 2.4.8 runtime, so neither is advertised. The code itself stays compatible wider: PHPCompatibility checks testVersion 8.1-8.5, and the unit suite also passes on 8.5. A version outside the validation matrix is still never advertised, magento/module-ui declared on Sender where its classes are extended, and a per-package LICENSE.
  • Documentation: how images actually sync (two routes, one switch), the version-parity requirement, the measured merge ceiling, and the dry-run's reporting scope stated in the wizard itself.

Added (initial release)

  • Connections and pairing: one-time pairing codes to establish trust between a sender and a receiver instance, with per-connection HMAC secrets minted and encrypted at rest; admin UI to add, enable/disable, revoke and delete connections.
  • Sync profiles: push content and configuration from a sender to a paired receiver, scoped by connection and by a set of entity-provider codes, with change-detection so only new or changed items are pushed on each run. Grid mass-actions and per-form buttons to stage or immediately push individual items.
  • Built-in push-sync entity providers: store configuration (allow/deny-listed paths only), store views, CMS blocks (including referenced media) and CMS pages, customer groups, and catalog (EAV) product attributes.
  • Merge engine: cross-instance identity resolution for structural data that already exists independently on both instances, matching rows by a stable natural key (never by raw database id) and persisting the id-to-id translation per connection so dependent entities' foreign keys resolve correctly across environments.
  • Built-in merge providers: store views, websites, store groups, product (EAV) attributes, attribute sets, customer groups, categories (matched by a root-qualified <root>/<url_key path>, with parent-category translation across instances) and products of every type — simple, virtual, grouped, configurable, bundle and downloadable — matched by SKU, with attribute-set/website/category references translated through the identity map and select/multiselect values carried as labels resolved against the receiver's own options. A whitelist of safe fields is the only thing a product merge may write; a product's type is never changed.
  • Merge Wizard: guided admin UI to run a merge one entity type at a time — connection and type selection, a read-only probe/reconciliation preview, a drag-and-drop (with full click/keyboard equivalent) mapping editor for category trees, a mandatory dry-run before anything is written, and a confirmation step before commit.
  • Audit log: pairing, sync, push, staging and merge actions are submitted to the audit sink with the acting admin, timestamp, connection, before/after status and a structured summary — including refused pairing attempts. Audit persistence is best effort so it cannot roll back an already-committed business operation; sink failures are written to the application log for monitoring.
  • Post-import consistency: affected indexers are invalidated and affected caches are cleaned automatically after a successful push or merge, so imported changes become visible without a blocking reindex inside the request.
  • Extension points: a snapshot/replay provider contract for content-shaped entities and a reconciliation provider contract for cross-instance structural entities, so third-party modules can add their own syncable/mergeable entity types without modifying the core modules.
  • Two-instance end-to-end test suite (Playwright) covering pairing, sync profiles, media transfer, all merge types, indexing and storefront visibility.
  • CMS multi-store fidelity: CMS blocks and pages travel with their store assignments as portable store codes; the receiver resolves them to its own store ids and matches rows by identifier plus store scope, so store-scoped content lands on the matching store view instead of being globalized, and same-identifier rows in different scopes are tracked, pushed and retried independently.
  • Honest outcome reporting end to end: receivers report per-item applied/failed identifiers, the sender records only genuinely-applied items as synced (failed rows retry on the next run), profile runs report "N pushed, M failed" instead of unqualified success, and a merge whose report contains row errors returns status: partial — surfaced in the wizard as "Completed with N row errors".
  • Staged selection scopes profile runs: items staged to a profile restrict the next Sync now for that provider to exactly those items (an empty staged set keeps full-coverage semantics).
  • Hourly cleanup cron purging expired pairing-bootstrap rows and replay-guard nonces past a 24-hour retention floor.
  • Receiver-side audit rows for every push-import (provider, applied/failed counts) alongside the existing merge-commit auditing.

Entity coverage added during 1.0.0 development

  • Every product type merges, not just simple. Configurable products carry their super-attributes by attribute_code and their variants by SKU, with option values resolved by label through the same translator the child products use, so parent and children can never disagree. Bundles carry options by title and selections by SKU; downloadables carry links and samples by title, with their files. Grouped products need no special handling — their children are ordinary associated product links. Product links (related/up-sell/cross-sell), tier prices (customer group and website as codes, * preserved as the wildcard it is) and the media gallery ride along for every type. Product types are an extension point: a third-party type is supported by registering a handler, and a type with no handler is refused rather than merged into a shell.

  • Catalog price rules, cart price rules and widgets can now be merged, alongside a customer-group provider that the rules depend on. What makes these different from earlier entity types is that they are mostly references: a price rule's conditions embed category ids, a widget points at a theme, a CMS block and a set of categories. Every such reference travels as a portable key (a code, an identifier, a path) and is resolved against the receiver's own data — an unresolvable one fails that row naming exactly what is missing, rather than being dropped (which silently narrows a discount or a widget's reach) or kept (which silently re-points it at whatever owns that id on the other side).

  • Catalog price rules invalidate the catalogrule indexers after import, so a merged rule takes effect on the next reindex without a blocking apply inside the request.

  • Customer groups carry their tax class by NAME rather than id, so a group cannot land under the wrong tax class on an instance whose tax classes were created in a different order.

  • A price rule whose conditions filter on a select attribute is refused rather than merged with ids that mean something else. Those conditions embed eav_attribute_option ids. Detection runs on the SENDER, where the attribute definitely exists — judged only on the receiver, an attribute absent there looks like an ordinary scalar condition and its raw id passes straight through, discounting whatever owns that id. Both rule providers now also declare product_attribute as a dependency.

  • Customizable product options now merge. The "add engraving", "choose gift wrap", "upload your artwork" fields an admin defines per product — distinct from a configurable's options (which select between child products) and from an attribute's options (shared select values), and previously not carried at all. A product arriving without its options is sold without its upsell, or without a required option, in a configuration the merchant never intended to offer. Options and their values are matched by title and their ids reused, because Magento deletes any option whose id is absent from the incoming set — recreating them would leave every quote item, order item and wishlist entry pointing at rows that no longer exist. Per-store title and price overrides are not carried; that boundary is stated in the release notes.

  • Swatches travel with their attribute. A colour or image swatch attribute merged fine and then rendered as a plain dropdown on the receiver — every option present with the right label, but none of the coloured squares. Both halves now travel: the attribute's swatch_input_type (with update_product_preview_image and use_product_image_for_swatch), and each option's swatch row, keyed by the option's admin label rather than its auto-increment id and by store code for per-store text swatches. Colour values are portable strings; image swatches carry their file, always and without an opt-in switch — unlike product gallery images, because a swatch without its image is a blank square rather than a missing picture, and a swatch image is a thumbnail.

  • Attribute options now carry their per-store labels. An option has two kinds of label: the admin label, which is its identity here (it is what a product merge carries a value as, and what an option merge matches on), and a label per store view, which is how each storefront spells it. Only the admin label used to travel — so a merged "Red" stayed "Red" on the French storefront where the sender says "Rouge", silently, and visibly only to a customer browsing that store. Labels travel keyed by store code; one naming a store the receiver does not have is dropped rather than failing the attribute, since it cannot change what an option means. Options that already exist on the receiver keep all of their own labels: the admin label is the match key, so rewriting labels would be indistinguishable from adding an option and would change what every product already using it displays.

  • Hand-made URL rewrites can now be merged — and only those. Magento derives autogenerated rewrites from an entity's url_key whenever that entity is saved, and a merge saves through those same repositories, so the receiver builds its own pointing at its own ids. Carrying them would be actively harmful: url_rewrite is unique on (request_path, store_id), so a sender's row would collide with the receiver's own for the same page, and its target would embed the sender's entity id — a 404, or a redirect to somebody else's product. What cannot be regenerated is the rewrite an admin typed in: a vanity URL, a campaign redirect, a permanent move after a rename. Those travel, keyed by store code plus request path, with entity-bound targets rebuilt from the receiver's own id rather than string-patched.

  • Attribute sets keep their group structure. A merged set used to clone the receiver's Default set for its skeleton and then drop every attribute into that skeleton's default group, so a set organised on staging into custom tabs arrived as one flat pile under "Product Details". Groups now travel by name with the attribute codes in each, are created on the target set when missing, and are never removed or renamed — removing one would move every attribute in it on an instance where someone may have arranged them deliberately.

  • Newsletter templates and the Page Builder template library can now be merged. Newsletter templates match by template_code; Magento soft-deletes them by flipping template_actual, so deleted templates are neither listed nor revived — a merge does not overrule a deletion made on the receiving side. Page Builder content already travelled inside CMS block and page HTML; what did not was the saved layout LIBRARY an admin builds on staging and then cannot find in production, now matched by name.

  • Tax classes, rates and rules can now be merged. A tax rule is barely a row — its own columns are three numbers, and everything that gives it meaning lives in tax_calculation as a matrix of ids: which customer tax classes, by which product tax classes, at which rates. All three travel as portable keys instead: rates by their unique code, tax classes by name within type (a tax class name is not unique on its own — the table has no unique constraint, and "Taxable Goods" as a customer class and as a product class are different rows meaning different things). A tax rate's region travels as a region code rather than a directory id, and its per-store display titles by store code.

    An unresolvable class or rate fails the row naming what is missing; it is never dropped and never defaulted. Dropping a rate silently stops charging that tax, dropping a customer class silently narrows who is taxed, and defaulting either charges tax that should not have been. Only the per-store rate titles are allowed to be dropped, being the one reference here that cannot change what a customer is charged. Customer groups now declare tax classes as a dependency, so a batch carrying both creates the classes first.

  • Transactional email templates can now be merged, matched by their template name (which Magento enforces as unique). Only admin-created templates are involved — the built-in defaults live in XML, not in the database. Subject, body, styles, type and sender overrides travel; the row id and this instance's own timestamps do not, and an unchanged template is not re-saved, so "last modified" keeps meaning what an admin expects.

    One consequence is worth knowing: a merged template gets a new id on the receiver, and Magento assigns custom templates to events by storing that raw numeric id in configuration (built-in ones are stored as a portable identifier instead). Configuration therefore refuses to promote a bare numeric value on a template path at all, rather than trusting the allow-list to stay narrow — assign merged templates in the receiver's own admin.

  • Store groups can now be merged, which makes a whole new storefront promotable in one pass. Websites and store views could both be merged, but the layer between them could not — and two deliberate safety rules turned that into a dead end rather than an inconvenience: a website merge creates the website row without fabricating a default group (that would mean guessing a root category), and a store-view merge refuses to create a view under a group-less website rather than writing it into Magento's reserved group 0. Merging a new website together with its store views therefore required hand-creating the group in the middle. Store groups match by code and carry both of their foreign keys as portable references: the website as a code, and the root category as a NAME — the one reference in the module that cannot use the identity map, because root categories are deliberately excluded from category merging. An unresolvable root category fails the row rather than falling back to the receiver's default root, since attaching a storefront to the wrong catalog is not something a merge should ever infer. Existing groups are renamed but never re-rooted or re-parented.

Security

  • All outbound requests verify TLS certificates and hostnames by default; insecure TLS is an explicit, clearly-labelled dev-only opt-in that must never be enabled in production.
  • HTTPS is required for the pairing exchange (in both directions) unless that same dev-only flag is set, since the pairing response carries a newly-minted shared secret.
  • Every signed request is authenticated per-connection via an HMAC-SHA256 canonical-string scheme with signature verification, a timestamp freshness window and single-use nonce enforcement to prevent replay.
  • Inbound connection fingerprint lookup is scoped strictly to receiver-role connections, so a connection's outbound credentials cannot be reused to authorize inbound writes.
  • Receivers enforce a configurable cap on active inbound connections, with automatic and manual locking of new pairing attempts, and admin lock/unlock actions that take effect immediately.
  • Pairing-code redemption is atomic and single-use under concurrent requests; the inbound cap check is serialized so concurrent pairing attempts cannot both succeed past the limit.
  • Destructive and state-changing admin actions (connection lifecycle, pairing generation, profile and merge actions) require POST with a valid admin form key, closing a class of cross-site request forgery.
  • A hardcoded, non-configurable set of instance-identity and secret-bearing configuration paths (base URLs, admin/security settings, store identity, encryption keys) can never be promoted by a sync, regardless of any configured allow-list; encrypted configuration values are refused outright.
  • Manual entity mappings made through the Merge Wizard are validated against the same eligibility rules the automatic matcher uses before being recorded, preventing an operator-supplied mapping from creating an orphaned or unsafe identity record.
  • Unauthenticated legacy transfer endpoints from an early prototype were removed entirely.
  • Media is written only for content that actually imported. Inline media used to be unpacked before the entity import ran, so a failed import could leave an orphaned file behind — or, worse, could already have overwritten an existing file at that path. Media now lands after the import and only for the items the provider reports as applied, adding a third bound (import outcome) to the existing provider-capability and payload-reference bounds. Withheld entries are audited.
  • A merge commit now provably ships the payload it previewed. Both the dry-run and the commit export the local rows exactly once per request: the dry-run binds its preview token to the very rows it showed, and the commit ships the very rows it verified. Previously each step exported a second time to hash or to write, leaving a window in which a concurrent edit could make "commit what you previewed" untrue while every other check still passed.
  • The direct merge trigger can no longer skip the preview quietly. Merge/Run defaults to dry-run and refuses a write unless confirm_direct_commit=1 is passed alongside dry_run=0; accepted direct writes are audited under their own direct_commit action, so the trail distinguishes them from preview-reviewed wizard commits.
  • A sender's media authority is bounded by the entities it is pushing. Inline media bundles are intersected with the paths the payload's own items reference, so a valid image at an unrelated path can no longer overwrite an existing file by riding along with unrelated content (and never lands if the item import fails). Dropped entries are audited.
  • Store-view push no longer risks Magento's reserved scope. Creating a store view under a website with no usable default store group is reported as a failed row and retried later, instead of being written into group 0 — the same never-guess rule the merge path already enforced.
  • Media pipeline hardening: inline media entries are rejected unless they carry a valid sha256 (integrity is mandatory, not optional), use an image-only extension allowlist (jpg/jpeg/png/gif/webp — no SVG, no scripts, no server-config files), contain no dot-prefixed path segment (.htaccess-class writes are impossible), pass a magic-bytes MIME check matching the claimed extension, and fit per-file (10 MB) and per-bundle (64 MB) caps; media bundles are only unpacked at all for providers that declare media support, and every rejected entry is audited.
  • Preview-token commit binding: a merge commit requires a one-use, ~15-minute server-issued HMAC token minted by the dry-run and bound to the connection, type, mappings and exact payload hash; the commit re-exports and re-verifies, so source data changing after the preview — or replaying an old token — is rejected with an explicit "run a fresh dry-run" error. A dry-run with row errors still arms the commit, but the confirmation dialog explicitly warns how many rows failed.
  • Merge endpoints enforce connection lifecycle server-side: probe/dry-run/commit/direct-run reject disabled, revoked, expired or receiver-role connections regardless of what the UI offered.
  • Profile runs and staged pushes enforce the same outbound lifecycle guard: disabled, revoked, expired and receiver-role connections are rejected server-side before a payload can leave the instance.
  • No scope guessing in structural merges: a store view whose website can't be resolved on the receiver is a reported per-row error, never silently attached to the default website; creating a store view under a merge-created website without a default group is an explicit error instead of a broken row.
  • Connection reactivation is serialized under the same named lock as pairing, so concurrent re-activations can't race past the inbound cap.

Changed

  • Sync profiles are explicitly manual-only: the vestigial Scheduled/Realtime mode and status UI, a placeholder Transfers page, unused connection columns (capabilities, ip_allowlist, allow_immediate, allow_scheduled), dead tables from an unshipped queued-transfer design and 17 unreferenced ACL resources were removed so the schema, UI and ACL describe only what actually works.
  • Deleting a sync profile now also deletes its per-item tracking rows.
  • PHPCS warnings are now fatal in CI. The accepted baseline is enumerated with a justification per rule in each module's phpcs.xml.dist — documentation-style sniffs, plus one legacy PSR2 control-structure sniff that directly contradicts the PSR-12 rule the codebase follows — so any sniff outside that list now fails the build instead of scrolling past in the log.
  • Receiver media decisions moved out of the sync controller into AppliedMediaWriter, which owns the capability/outcome/reference bounds as one testable unit.
  • Module-level Composer phpcs/phpmd scripts cover the Cron directory, matching CI.
  • Fixed a PHPUnit 10 deprecation from a non-static integration data provider.
  • Sync profiles dropped their vestigial mode, apply_mode and status columns (none was read; apply_mode defaulted every profile to a queue that does not exist), along with the last two unused tables and one unused audit column from the unshipped queued-transfer design, and the dead TransferStatus constants class.
  • CI now analyses the Cron directory it previously skipped (PHPCS, PHPMD and PHPStan), and checks out sources with a non-deprecated runtime.
  • Test coverage closed on two previously untested units: the media write-eligibility rules (AppliedMediaWriter, including the scope-suffix case where a partially-resolvable row still imports) and the unauthenticated pairing endpoint's own contract (malformed-body rejection before the service is reached, and each refusal class mapping to its own status).
  • Runtime Magento module dependencies are now explicit: Core declares Cron, Directory and Email, and Sender/Receiver require a compatible ^1.0 Core release instead of an unconstrained version.

Fixed

  • Index and cache invalidation now covers every merge entity type, closing a gap where merged structural data could leave the storefront and admin showing stale results.
  • Category matching normalizes url_key the same way Magento's own save path does, so re-running a merge against non-canonical or blank-url-key categories matches the previously created category instead of creating a duplicate.
  • Attribute-set merging resolves carried attribute references through the identity map rather than by raw id, so attribute membership survives translation between environments with different ids.
  • Push-sync customer groups carry a typed tax-class key (CUSTOMER:<name>) and reject missing or wrong-type classes instead of exporting a sender-local numeric id or guessing a fallback id.
Changelog — ContentBridge — Deployment & Operations — Extensions | qoliber Docs