Changelog
Current version: 1.0.0
[Unreleased]
[1.0.0] - 2026-08-18
Added
- 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 rawtax_class_idwith 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 sendstax_class_idand has every customer-group row refused, so upgrade the receiver first and run matching versions on both instances. - CMS block references embedded in content are translated. A
{{block}}/{{widget}}directive referencing another block by numericblock_idresolves by primary key, so shipping it verbatim rendered whichever block owned that number on the receiver — the page still loaded, which is what made it dangerous. The reference now travels as the block's identifier and is rewritten to the receiver's own id, keeping Page Builder (which emits the numeric form and whose stage filters on the numeric column) 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. - 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_idcannot reach the push path using a receiver-role, disabled, revoked or expired connection.
Fixed
- 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-linkscan 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
authorsblock, PHP 8.5 added to the supported range (verified by parsing every module file on 8.1 and 8.5),magento/module-uideclared on Sender where its classes are extended, and a per-packageLICENSE. - 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-relative
url_keypath, 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_codeand 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 ordinaryassociatedproduct 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
catalogruleindexers 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_optionids. 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 declareproduct_attributeas 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(withupdate_product_preview_imageanduse_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_keywhenever 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_rewriteis 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 flippingtemplate_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_calculationas 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/Rundefaults to dry-run and refuses a write unlessconfirm_direct_commit=1is passed alongsidedry_run=0; accepted direct writes are audited under their owndirect_commitaction, 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/phpmdscripts cover theCrondirectory, matching CI. - Fixed a PHPUnit 10 deprecation from a non-static integration data provider.
- Sync profiles dropped their vestigial
mode,apply_modeandstatuscolumns (none was read;apply_modedefaulted 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 deadTransferStatusconstants class. - CI now analyses the
Crondirectory 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.0Core 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_keythe 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.