Changelog
Current version: 1.4.0
Every module of the suite is released on the same version. Each release below lists the modules it changed, in their own words.
1.4.0 — 2026-09-11
Qoliber_GdprAdmin
Added
- Translations. The module shipped none, so its admin screens were English on every locale. Every phrase it shows is now translated in
en_US,de_DE,pl_PL,fr_FRandes_ES, in this module, so it is translated when installed on its own. French and Spanish are machine-quality and awaiting native review.
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - No other change in this module. Its PHP, templates and configuration are identical to 1.3.0. The substance of 1.4.0 is multi-store and multi-language support and the evidence behind it: consent wording, visibility and evidence per store view and website (
qoliber/gdpr-consent), privacy policies per store view with acceptance recorded on the server (qoliber/gdpr-policy,qoliber/gdpr-policy-hyva), a cookie banner in every store view's language with every decision on record (qoliber/gdpr-cookie,qoliber/gdpr-cookie-hyva), data-subject retention and erasure fixes (qoliber/gdpr-data-subject), and every phrase translated into German, Polish, French and Spanish.
Fixed
- A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Qoliber_GdprConsent
Added
- Consent wording per store view. Title, description, checkbox text, link text, link URL and CMS page can be set for each store view under Store Views & Translations in the consent form. A blank field inherits the default, field by field, so a translator who fills in only the title never blanks the checkbox text beside it. The admin scope always shows and saves the defaults.
- Consent visibility per store view and per website. A consent can be limited to chosen store views, or to a whole website: Entire website, including store views added later covers a store view added to that website afterwards, which choosing its current store views one by one does not. A consent with no assignment stays visible everywhere, so upgrading never hides a required consent; All Store Views absorbs any specific selection.
- Evidence of what was shown. The consent log snapshot records the store view's wording as displayed, how it was displayed, the website, locale, link text, link URL, link target and CMS page, and a SHA-256 fingerprint of the wording (GDPR Art. 7(1)). Two acceptances of the same text share a fingerprint; editing the text changes it.
- Evidence of the linked legal document. "I accept the terms" means the terms page as it read when the box was ticked. The snapshot now also records the linked CMS page's id, URL key, title, last edit and a hash of its content, and these count towards the record's fingerprint, so a record made after the page was edited can be told apart from one made before.
- Required consents are required on the server. The consent block marks the registration, contact and newsletter form it renders into, and a marked form goes through only with every required consent it offered ticked; otherwise the shopper is sent back with the form refilled and told which consents are missing. Luma checked them with JavaScript alone, so a browser without it, or any request made without the page, got through. A form that never showed the consents (a custom theme, a third-party popup) carries no mark and is left alone.
- Cookie-banner decisions are on record as what they were. The consent log endpoint accepts
deniedand stores a banner decision in a fixed shape — the validated categories, the banner version and what triggered it (banner,embed,gpc,do_not_sell) — dropping anything else a client sends; other scopes are capped at 8 KB. Hyvä refusals were rejected with HTTP 400 and never recorded. - Translations. Every phrase is translated in
en_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review.
Fixed
- Consents showed in the default wording, and on every store view of a multi-language shop (reported on
gdpr-consent-hyva#1). The registration, newsletter and contact forms, the Luma checkout (config provider and block), the preferences page and the consent log now resolve the current store view's wording and visibility through one rule (Model\ConsentDefinition\StoreLabels). - Only consents that were offered are recorded. The tracker recorded any consent id or code the browser posted — a disabled consent, one configured for another form, one assigned to another store view — creating evidence of agreements never shown. It now records a consent only when it is enabled, visible on the store view and configured for the submitting form, with that store view's wording in the snapshot.
- A required consent is never ticked for the shopper. The registration, newsletter and contact forms rendered every required consent ticked, so the form recorded an acceptance nobody gave (CJEU C-673/17, Planet49). Required now means "must be ticked to submit".
- Withdrawal always works (Art. 7(3)). A consent the customer has given stays withdrawable after the merchant disables or unassigns it, and a withdrawal is recorded only for a consent the customer gave. The preferences page lists the store view's consents in its wording plus given-but-no-longer-offered ones, labels history with the wording agreed to rather than today's title, formats dates in the store's locale and reads the latest records in one query instead of one per consent.
- Deleting a store view no longer deletes its consent evidence. The consent log's store key cascaded, so closing a market or merging a language store view erased every consent record given there. The key is removed; a record keeps the id of the store view it was given on.
- Deleting the only store view a consent was restricted to no longer shows it everywhere. "No assignment" meant "everywhere", so the consent appeared on every store view — in another language, under another jurisdiction. A restricted consent now stays restricted and is shown nowhere until reassigned; the admin form then shows nothing selected, so saving cannot quietly make it global.
- Each website's retention period applies to its own consent evidence. The purge read only the default scope, so a website that turned it off (0) still had its evidence deleted on the default's schedule. Rows from the admin scope and from store views no website holds any more follow the default; the newest entry per person and consent is still kept.
- Contact-form consent names the right person, for what happened. It was recorded before Magento processed the form, so a rejected submission still left a consent row, and always as a guest's, so a signed-in customer's consent was missing from their account. It is now recorded after the message is accepted, against the customer.
- Registration adopts only guest consents from the account's own website when customer accounts are shared per website. It linked every guest consent with the new account's e-mail address from every website, including consents given on a website where that address is a different account.
- Only an explicit checkbox decision can become consent evidence. PHP's boolean cast treated any non-empty client value — including
"false"or an arbitrary token — as acceptance. Required-form gates now accept only the three representations the supported adapters produce (true,1,"1"), and the evidence writer ignores values that are neither an explicit acceptance nor an explicit rejection. The stateless normalizer is an optional constructor-tail dependency so stale generated interceptors remain instantiable during deployment beforesetup:di:compile. - Omitting the form marker no longer switches enforcement off. The check returned "nothing missing" when the request carried no
qoliber_gdpr_consent_formfield, which is the one field an attacker controls entirely: dropping it from the POST submitted the form with no consent at all, and the marker was trusted for the form location too. The form location is now supplied by the controller plugin that owns the request, and whether consent is required is decided by the server's configuration and the store view's own definitions. A theme that renders no consent block is a deployment defect, not permission to submit without consent. - The checkout refuses a shopper's order without the required consents, on the server. The checkout API stashed whatever consent data a client sent and never checked it, so a request made without the page placed the order with no consent recorded at all.
savePaymentInformationAndPlaceOrder,savePaymentInformationandplaceOrdernow go through one gate that counts the answers on the payment, falling back to those stashed in the session, and refuses the order naming the consents still to accept. Registered for both the REST and SOAP areas, so neither is a way round the other. The gate is inert unless the merchant has turned the checkout consent step on. Refused is a customer or a guest — which is also who an attacker calling the API directly has to be, since forging an integration token needs admin credentials. An integration or admin token is a trusted server-side caller with no shopper in the request to have consented — an ERP pushing orders, a subscription job, a headless front end — and is let through; answers it does send are still recorded. Admin order creation is untouched, the plugins being registered in the web API areas only. - A seeded "Terms and Conditions" consent links to its own document, under its own name. The two shipped consents were seeded with no
link_text, and the renderers filled the gap with the words "Privacy Policy" — so a store showed "I accept the Privacy Policy" against the terms-and-conditions consent, linking to a page that is not what the shopper agreed to. The label now comes from the consent's own link text, then the linked CMS page's title, then the consent's title, and the CMS renderer honours alink_urlwhen no CMS page is chosen.ReconcileSeededConsentLinksfills in the two shipped consents on existing installs, and only for rows still holding exactly the values we shipped — a merchant's own wording is left alone. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Known limitations
- A third-party Luma payment method that places the order without going through the checkout's
place-ordermixin sends no consent answers, and the server gate refuses the order rather than recording an order nobody consented to. The shopper sees which consents are missing but the checkout's own boxes are already ticked, so there is nothing for them to do: the merchant must make that method carry the consent data, or turn the checkout consent step off. This only arises where the step is switched on.
Upgrade notes
- Schema: new tables
qoliber_gdpr_consent_storeandqoliber_gdpr_consent_website; a new columnis_restrictedonqoliber_gdpr_consent_definition;link_text,link_urlandcms_page_idonqoliber_gdpr_consent_store_label(which shipped in 1.0.0 but was never read or written); and the foreign key fromqoliber_gdpr_consent_customer_log.store_idtostoreis dropped — the column and its values stay. TheBackfillConsentRestrictiondata patch marks every consent restricted to specific store views today. Runbin/magento setup:upgrade, and in production modesetup:di:compileandsetup:static-content:deploy: the release adds plugins to Magento's registration, contact and newsletter controllers. - Required consents start unticked. Shoppers now tick them themselves on every form.
- A consent checkbox must post
1. The shipped templates always did, and the supported checkout adapters sendtrue,1or"1". A custom form whose checkbox carries novalueattribute posts the browser defaulton, which is no longer read as acceptance — give itvalue="1". - Registration, contact and newsletter submissions are refused without the required consents, with or without JavaScript, and whether or not the request carries the form marker. A theme that renders none of the consent block therefore blocks those submissions: render the block, or unset the consents as required for that form.
- Where the checkout consent step is switched on, a shopper's order placed without the required consents is refused by the API — see Known limitations for third-party Luma payment methods. Orders placed with an integration or admin token are not affected, so an ERP, subscription or headless order flow keeps working.
- The
ReconcileSeededConsentLinksdata patch runs on upgrade and touches only the two shipped consents, and only while they still hold the values we seeded. ConsentDefinition\Collection::addStoreFilter()is new;qoliber/gdpr-consent-hyva1.4.0 calls it and requires this release.- Constructors changed (only a subclass that overrides one needs updating):
Cron\PurgeConsentLogs,Model\ConsentDefinition\DataProvider,Plugin\Contact\Controller\Index\PostPlugin,Service\ConsentLinkService,Service\ConsentTracker(no longer takes the consent repository) andViewModel\ConsentHistory.
Qoliber_GdprConsentHyva
Fixed
- The Hyvä checkout showed every consent in the default wording on every store view. It now asks for the current store view's wording and visibility, like the Luma checkout (reported on this module's issue #1).
- A required consent is never ticked for the shopper. The checkout component marked required consents accepted when it loaded, so they rendered ticked, and because only a change writes the session, a shopper who touched nothing placed the order with no consent recorded at all. Every box now starts unticked unless this shopper ticked it earlier in the session; Accept All and Required Only stay, being explicit clicks. The registration, newsletter and contact forms no longer render required consents ticked, and the legacy list template no longer renders them disabled.
- The registration, newsletter and contact forms carry the mark that has
qoliber/gdpr-consentenforce their required consents on the server. - Switching the checkout consent step off now switches it off on Hyvä.
qoliber_gdpr/consent/checkout_enabledwas read by the Luma checkout alone: on Hyvä the wrapper and its title still rendered, the Magewire component still refused to let the shopper continue without consents it had been told not to ask for, and the Alpine block was still emitted. One shared service inqoliber/gdpr-consent1.4.0 now answers the question, and the wrapper, the component, the layout (throughifconfig) and the Alpine block all ask it. - The consent step survives a strict CSP. Hyvä's
registerInlineScript()puts the nonce on the last script tag in the output buffer, and a literal<scriptin a comment in the Alpine template took it — the real component then ran without a nonce and was blocked, so the consent step never initialised and Place Order did nothing on a Hyvä CSP store. The comment is reworded, andverify-release-findingsfails on a second script tag inside any CSP-registered block. - A shopper's answers reach the order they belong to. What the order observer records is the session stash, and only a change hook wrote it — so a state that reached the end of the step any other way passed validation with nothing on record. The step now writes what it validates. Clearing after an order called the magic
unsData(), which unsets a field literally named "data" and left the stash untouched, so the next order in the session inherited the previous one's answers; it now callsunsetData(), and every box starts unticked again rather than re-ticking the required ones for the shopper. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Added
- Translations. The module shipped none. Every phrase it shows is now translated in
en_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review.
Upgrade notes
- Requires
qoliber/gdpr-consent^1.4, the release that addsConsentDefinition\Collection::addStoreFilter()and the server-side consent check. The constraint is raised incomposer.json, so Composer refuses an update that would pair this module with an oldergdpr-consent(where the Hyvä checkout would fail with an undefined-method error); update both together, or runcomposer update qoliber/gdpr-metapackage -W. - Constructors changed:
Magewire\Checkout\GdprConsentsandViewModel\CheckoutConsentsAvailabilitygainStoreManagerInterface.
Qoliber_GdprCookie
Added
- Store view translations for cookie categories and cookies. The category and cookie forms gain a Store View Translations section: a category's name and description, and a cookie's description, per store view. The banner applies them field by field; a blank field inherits the default. The tables behind them existed since 1.0.0 and were read and written by nothing.
- Translations. Every phrase is translated in
en_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review.
Fixed
- The cookie banner speaks the store view's language. A German store view showed an entirely English banner: its copy, category names, cookie descriptions and durations came from configuration and seed data and never passed through the translator. While a store view still shows the shipped English wording, it is now shown translated (
Model\ShippedTexts); wording the merchant typed is shown as typed. Luma's no-JavaScript notice and the blocked-script fallback were hard-coded English and are translated too. - Every banner decision is on record as what it was. Luma posted every save as "granted", so a refusal read as consent to the required categories, and its GPC opt-out and Do Not Sell link wrote the browser only. Allowing nothing beyond the required categories is now recorded as a refusal, whichever button made it, and GPC and Do Not Sell opt-outs are recorded with what triggered them. Luma records through the cookie manager, the one place every decision is saved.
- A decision names the banner it answered. Both themes sent version "1.0". The version is now a fingerprint of what the store view's banner shows — copy, categories, cookies and language (
ViewModel\CookieConsent::getBannerVersion()). It is evidence only: a new version does not ask shoppers again. - Hyvä and Luma read the same configured banner texts, and cookie type labels reach the templates instead of raw type codes.
- Google Consent Mode signals say what the shopper actually chose. The category-to-signal mapping was hardcoded in four places that disagreed with each other: Hyvä knew only
analyticsandmarketingand ignored the Google Services category outright, so accepting everything on Hyvä leftanalytics_storagedenied while the same click on Luma granted it, andfunctionality_storagerode with the always-on Essential category on both themes — granted on Deny All, although the seeded Functionality category is refusable. One map now serves every theme and both tag integrations (Model\GoogleConsent\SignalMap), built from the store view's own categories, so a category a merchant added is mapped too. Essential and Necessary carry onlysecurity_storage; Functionality carriesfunctionality_storageand is granted only when the shopper allows it. - The Google configuration has one shape. The view model emitted two differently-shaped arrays for the same settings, in
snake_caseandcamelCase, and templates picked whichever they were written against.googleConsentConfig()is now the single source, incamelCaseand carryingsignals; thesnake_casekeys remain as deprecated aliases for one release, so a merchant's own template keeps working. - The cookie detail table stops pretending to save fields it has no column for. The Google seed set a purpose and a sort order on each cookie and spelled the active flag
is_enabled; Magento's save filters a model down to the real columns without a word, so all three went nowhere and the install runs once, so nothing surfaced it. Nothing was lost — the two invented fields had no column andis_activedefaults to true — so there is nothing to backfill. What a cookie is for stays in its description; which signal governs it follows from its category. An integration test reloads the seeded rows from the database, andqoliber/gdpr-core1.4.0 now fails the suite for any seed patch, in any module, that writes a field its table has no column for. - The seeded Analytics category no longer promises anonymity. It told shoppers their data was collected and reported "anonymously". Whether an analytics provider anonymises anything is the merchant's own configuration, and by default most do not — so a store that never edited the wording published our assurance as its own, on the one screen a shopper reads before deciding.
ReconcileAnalyticsCategoryWordinggives the corrected wording to installs seeded before it, touching only rows still holding the exact text we shipped, store-view translations included; a merchant who reworded it is left alone. - The shipped "How we use cookies" copy no longer promises what a merchant's cookies do not collect. It stated as fact that first-party cookies "do not collect any of your personally identifiable data" — a first-party session or customer cookie identifies a shopper perfectly well, and a merchant who never edited the wording published our guess as their own statement. It now says what cookies are for and ties it to the shopper's own choice, in all five shipped locales, and a unit guard fails on an assurance of this kind in any shipped default.
- A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Upgrade notes
- No schema change. Run
bin/magento setup:upgrade, and in production modesetup:di:compileandsetup:static-content:deploy(the cookie manager's JavaScript changed). ReconcileAnalyticsCategoryWordingruns on upgrade and rewords the Analytics category only where it still reads exactly as shipped; review it afterwards if the wording matters to your legal text.- Refusals reach the consent log with
qoliber/gdpr-consent1.4.0, whose endpoint accepts them; an oldergdpr-consentrejects them as before.qoliber/gdpr-metapackage1.4.0 installs the two together. ViewModel\CookieConsenttakes four new constructor dependencies, all optional and resolved on their own, so a subclass built against the 1.3 constructor (such asqoliber/gdpr-frontend's) keeps working. Constructors that changed outright:Controller\Adminhtml\CookieCategory\SaveandController\Adminhtml\CookieDetail\SavegainStoreTexts;Model\CookieCategory\DataProviderandModel\CookieDetail\DataProvidergainStoreTextsandStoreTextsMeta.
Qoliber_GdprCookieHyva
Fixed
- The banner speaks the store view's language and shows the merchant's texts. Hyvä ignored the configured banner texts and showed raw cookie type codes. It now shows the same configured, translated texts as Luma, and type labels.
- Refusing takes no more clicks than accepting. The centred banner offers Deny All on its first layer.
- Opt-outs are recorded as refusals. Do Not Sell and GPC opt-outs were posted as "granted". Every decision now posts what it was, what triggered it and the banner version.
- The category toggle's
click.stopuses a named handler instead of two emptyx-on:click.stop=""expressions, per the suite's Hyvä CSP convention. - Denying everything no longer grants Google functionality storage. The banner's category-to-signal map rode
functionality_storagealong with the always-on Essential category and started itgranted, so Deny All left it granted although the seeded Functionality category is refusable. It is now its own category's signal, denied until Functionality is allowed. The map itself comes from the server for this store view's own categories (googleConsent.signals, fromqoliber/gdpr-cookie1.4.0'sModel\GoogleConsent\SignalMap) rather than a hardcoded copy that knew nothing of categories a merchant added; the copy here remains only as a fallback. - The dormant
hyva/google-consent-init.phtmlcompatibility template now uses the canonical server-provided Consent Mode signal map andqoliber_gdpr_google_consentpayload. A merchant layout override can no longer restore from the obsolete category payload, grantfunctionality_storageunconditionally, or load either Google tag type before stored consent allows analytics or ads. Its single inline script remains registered through Hyvä CSP. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Added
- Translations. The module shipped none. Every phrase it shows is now translated in
en_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review.
Upgrade notes
- Requires
qoliber/gdpr-cookie^1.4 (raised from ^1.2). The type labels, the configured texts and the banner version come from its 1.4 view model; paired with 1.3, cookie types would show blank. Update both together, or runcomposer update qoliber/gdpr-metapackage -W. - Refusals reach the consent log with
qoliber/gdpr-consent1.4.0.
Qoliber_GdprCookieTemplates
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - No functional change in this module. Its code is identical to 1.3.0. The substance of 1.4.0 is multi-store and multi-language support and the evidence behind it: consent wording, visibility and evidence per store view and website (
qoliber/gdpr-consent), privacy policies per store view with acceptance recorded on the server (qoliber/gdpr-policy,qoliber/gdpr-policy-hyva), a cookie banner in every store view's language with every decision on record (qoliber/gdpr-cookie,qoliber/gdpr-cookie-hyva), data-subject retention and erasure fixes (qoliber/gdpr-data-subject), and every phrase translated into German, Polish, French and Spanish.
Fixed
- A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Qoliber_GdprCore
Added
- Translations. The module shipped none. Every phrase it shows is now translated in
en_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review. Test\Integration\Fixture\LanguageStoreViews(test code only): the German, French and second-website store views the store-view suites run on, moved here so modules that do not requireqoliber/gdpr-consentcan use them.
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - No functional change in this module. Its PHP and configuration are identical to 1.3.0.
TESTS.mdre-measured for 1.4.0.
Fixed
Test\Integration\CronScheduleExecutionTestfailed intermittently: Magento's cron runner executes one row per job per pass, and pending rows generated by an earlier pass could fall due before the test's own seeded row, which then stayed pending. The test now owns its job's pending rows. Reproduced on demand by planting a due, grid-aligned row.- A seed patch that writes a field its table has no column for now fails the suite. Magento's save filters a model down to the real columns in silence, and a seed patch runs once, on an install nobody watches — so two modules had been seeding fields that went nowhere for four releases.
Test\Integration\SeedPatchesWriteRealColumnsTestreads every seed patch of every module installed beside this one, resolves each patch's table through the factory it actually calls, and compares what it writes against the columns the database really has. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Tested on
- Mage-OS 2.2.1 on PHP 8.3 with MariaDB and OpenSearch, Luma and Hyvä: unit, integration and Playwright end-to-end suites (see
TESTS.md), including an upgrade from the 1.3.0 schema withsetup:upgrade. - PHP 8.1 to 8.5: every PHP and template file lints clean, and the PHP 8.1 floor is checked with Rector's downgrade set. The unit and integration suites pass on PHP 8.5.
- Not yet run on Magento Open Source 2.4.9 or with PHP 8.5 end to end.
composer.jsonallows both; the suite has not been run on that stack.
Qoliber_GdprDataSubject
Fixed
- The retention purge keeps open and held requests. It deleted requests by age alone — pending, in progress, awaiting an operator or on legal hold — and with them their status history, notes and e-mail log. It now deletes only closed requests that nothing holds, counted from their last update; 0 or empty turns it off.
- The expiry pass expires only requests waiting on the subject. It flipped any request with a lapsed link to "expired", silently dropping confirmed anonymizations the cron had not reached within 24 hours and overwriting completed outcomes. It never expires a request on legal hold.
- Account closure deletes the account. Magento refuses to delete a customer outside a secure area, so every closure run by cron ended "partially completed" with the account still there. The delete now runs as a secure operation and restores the previous flag.
- Erasure is not undone. The deletion and anonymization crons saved their stale in-memory copy of the request after the anonymizer had scrubbed it, writing the IP address, user agent and reason back.
- The anonymizer wiped every earlier request's outcome record; it keeps an allow-list of non-personal outcome keys, and a legal hold keeps its reason.
- Approved deletion and anonymization requests are picked up by their crons.
- The shopper hears about a decision. The approve and deny e-mail templates the admin controllers send were declared nowhere, so every decision e-mail failed and the shopper was never told. Both exist now; the denial states the reason and the right to lodge a complaint (Art. 12(4)).
- The subject's language. Guest exports built by cron are generated in the language of the subject's store view. Verification e-mail wording, request types, statuses, the recipient name and export headings are translated; phrases are passed to
__()as literals, so the translation collector sees them. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Added
- Translations. French and Spanish added; German and Polish complete. French and Spanish are machine-quality and awaiting native review. Polish follows the module's informal register.
ViewModel\ShopperLabelsandModel\Export\DataLabels: translated names for request statuses, data categories and fields, for the storefront pages and exports.
Upgrade notes
- No schema change. Run
bin/magento setup:upgrade, and in production modesetup:di:compile. - The retention setting changed meaning. Expired Request Cleanup (Days) is now Closed Request Retention (Days) (same path,
expired_request_days): it applies to closed requests only — completed, anonymization completed, rejected, denied, cancelled or expired — counted from their last update, and 0 or empty turns the purge off. Check the value against your retention policy. - Two new e-mail templates, so decision e-mails now reach shoppers: GDPR Request Approved and GDPR Request Denied. Review their wording, and override them per store view if needed, before upgrading.
qoliber/gdpr-data-subject-hyva1.4.0 andqoliber/gdpr-privacy-center1.4.0 require this release.- Constructors changed:
Cron\ProcessDataFetchRequests(+Emulation),Cron\ProcessDeletionRequests(+Registry),Model\DataConverter\CsvConverterandTxtConverter(+DataLabels),Service\EmailService(+RequestTypeSource) andViewModel\Privacy(+DataLabels).
Qoliber_GdprDataSubjectHyva
Fixed
- The anonymization and guest data pages speak the store view's language. Request statuses were printed as English codes on every store view, and data categories and fields were run through
__()as computed strings no translation collector can see. They come fromgdpr-data-subject's newViewModel\ShopperLabels. - The guest data page wrote a count and a word separately ("3 record(s)"), which breaks word order in other languages; it uses whole phrases with the count as a placeholder.
- A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Added
- Translations. The module shipped none. Every phrase it shows is now translated in
en_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review.
Upgrade notes
- Requires
qoliber/gdpr-data-subject^1.4 (raised from ^1.2) forViewModel\ShopperLabels; with an oldergdpr-data-subjectboth pages would fail to render. Update both together, or runcomposer update qoliber/gdpr-metapackage -W.
Qoliber_GdprDemo
Added
- Translations. The module shipped none. Every phrase it shows is now translated in
en_US,de_DE,pl_PL,fr_FRandes_ES, in this module, so it is translated when installed on its own. French and Spanish are machine-quality and awaiting native review.
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - No other change in this module. Its PHP, templates and configuration are identical to 1.3.0. The substance of 1.4.0 is multi-store and multi-language support and the evidence behind it: consent wording, visibility and evidence per store view and website (
qoliber/gdpr-consent), privacy policies per store view with acceptance recorded on the server (qoliber/gdpr-policy,qoliber/gdpr-policy-hyva), a cookie banner in every store view's language with every decision on record (qoliber/gdpr-cookie,qoliber/gdpr-cookie-hyva), data-subject retention and erasure fixes (qoliber/gdpr-data-subject), and every phrase translated into German, Polish, French and Spanish.
Fixed
- A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Qoliber_GdprEmbed
Fixed
- Embed consent evidence names a real provider and its own category. The endpoint validated the scope but stored the provider and category as the request named them, so any string could be put on record, and a click on a marketing embed could be recorded as consent to another category. Only a provider the store knows is recorded now, under that provider's own consent category.
- A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Added
- Translations. The module shipped
en_USonly, so the embed placeholder stayed English on every store view. Every phrase is now translated inen_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review.
Upgrade notes
- No schema change.
Model\EmbedConsentRecorder's constructor gainsEmbedProviderPool.
Qoliber_GdprFrontend
Added
- Translations. The module shipped none. Every phrase it shows is now translated in
en_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review. - The cookie banner texts this module ships as configuration defaults are now shown in the store view's language while a store view still uses them, through
qoliber/gdpr-cookie1.4.0 (Model\ShippedTexts). Wording a merchant typed is shown as typed.Test\Unit\BannerDefaultsAreTranslatableTestfails if a default is added that has no translation.
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - Its
CookieConsentview model subclass keeps working unchanged: the parent's new dependencies are optional.
Fixed
- The Google tag no longer grants functionality storage before the shopper has chosen.
consent/google-tag.phtmlhardcodedfunctionality_storage: 'granted'in itsgtag('consent', 'default', …)call, commented "always granted for necessary cookies" — it is not a necessary signal, and a shopper who then refused Functionality had already been counted as accepting it. The defaults now come from the store view's own category-to-signal map inqoliber/gdpr-cookie1.4.0 (Model\GoogleConsent\SignalMap), which grants onlysecurity_storageup front. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Qoliber_GdprFrontendHyva
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - Only the Google tag template changed (see Fixed); nothing to reconfigure.
Fixed
-
The Google tag no longer grants functionality storage before the shopper has chosen.
consent/google-tag.phtmlhardcodedfunctionality_storage: 'granted'in itsgtag('consent', 'default', …)call — it is not a necessary signal, and a shopper who then refused Functionality had already been counted as accepting it. The defaults now come from the store view's own category-to-signal map inqoliber/gdpr-cookie1.4.0 (Model\GoogleConsent\SignalMap), which grants onlysecurity_storageup front. This is the same correction made on the Luma side, so both themes send Google the same state. Paired withqoliber/gdpr-frontend1.3 the template falls back to its own copy of the map — correct, but blind to categories the merchant added; update the suite together, or runcomposer update qoliber/gdpr-metapackage -W. -
A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Qoliber_GdprGtm
Added
- Translations. The module shipped none. Every phrase it shows is now translated in
en_US,de_DE,pl_PL,fr_FRandes_ES, in this module, so it is translated when installed on its own. French and Spanish are machine-quality and awaiting native review.
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - The container bootstrap is rewritten around the Consent Mode API (see Fixed). Configuration is unchanged; nothing to reconfigure.
Fixed
- Google Consent Mode was never actually set. The template pushed named
consent_defaultandconsent_updateevents onto the dataLayer and calledgtag('consent', …)nowhere. A named event carries no consent state, so every Google tag in the container ran as though Consent Mode were not in use: a shopper who refused everything was still measured and still had advertising cookies written, while the banner reported their choice as honoured. The state is now set throughgtag('consent', 'default', …)before the container loads andgtag('consent', 'update', …)on every change. The named events are still pushed for containers built on them before 1.4 — as a notification alongside the state, never instead of it. - The signals follow the categories the shopper allowed. Defaults come from the store view's own category-to-signal map in
qoliber/gdpr-cookie1.4.0 (Model\GoogleConsent\SignalMap), sofunctionality_storagestarts denied and is granted only by the Functionality category, and onlysecurity_storageis granted before a shopper has chosen. - The bootstrap survives a strict CSP. The inline script is registered with Hyvä's
registerInlineScript()where Hyvä is present and rendered throughSecureHtmlRendererotherwise; unregistered, a strict policy blocked it and with it the whole Consent Mode setup, while the banner carried on as if all were well. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Qoliber_GdprPolicy
Fixed
- Every policy saved from the admin landed on store view 1. The store view field was a multiselect writing into a single-value column, and the database cast the array to 1 — whatever was picked, All Store Views included. It is now a single select, matching the data model: one policy per store view, and publishing archives the other published policies of that store view.
- Store views were not reliably served their own policy. Every storefront path took the first published policy the database returned, so the older All Store Views policy usually won over a store view's own. The store view's policy now comes first, newest first; the view model (and with it the acceptance evidence), the policy modal, the consent renderers and re-consent all use the same ordered lookup.
- The translated CMS privacy page picked in the admin is the one used. Per-language CMS pages usually share one URL key, and Magento's page selector then stores every page after the first as "identifier|page id". The module used that value as the identifier, so the German page chosen for the German store view was never linked, rendered or recorded. It is now normalised the way Magento's own page helper does it, and the page is looked up by URL key on the store view.
- CMS-page mode followed the wrong page on multi-language shops. The version was taken from whichever page carried the URL key first, on every store view, and the server-side version was a hash of the URL key that no page edit ever changed. The version is now the store view's own page and its last edit.
- Acceptance is evidenced before it counts. The accept endpoint set the acceptance cookie before writing the evidence, so a failed write still spared the shopper the modal. The cookie is now set only after the record is stored, and the Luma modal closes only once the server confirms it; otherwise it stays open with an error.
- Acceptance is per policy. It was tracked by version string alone, and the cookie is shared by every store view on the domain: accepting the All Store Views policy 1.0.0 counted as accepting the German policy 1.0.0, a different text. The cookie now names the policy.
- CMS-page mode records acceptances. It recorded nothing (the policy id was a required foreign key); the record now carries the store view's CMS page and which kind of policy was accepted.
- The latest answer decides. "Accepted" meant "an acceptance exists", so a later decline or withdrawal changed nothing: the customer stayed on record as accepting and was never asked again.
- Allowing cookies is not accepting the privacy policy. A cookie-manager mixin recorded a policy acceptance every time a shopper allowed cookies; it is removed, and the policy consent log endpoint no longer accepts a
cookie_consentcontext. - A decline is recorded as a decline. The accept endpoint cast the answer to a boolean, and
(bool) "false"is true, so a client posting a JavaScript boolean had its decline recorded as an acceptance. It reads the answer as a word now. - Fields that were written but never stored.
customer_email,consent_method,policy_typeandcountry_codewere declared only indb_schema_updates.xml, a file Magento never reads, so every save dropped them. They are real columns; the stray file is gone. - The policy consent log endpoint (
gdpr/consent/log) no longer stores an address a guest typed (nobody verified it) and requires an explicit context instead of defaulting to an acceptance. Its JavaScript module posted a JSON body the endpoint ignored; it posts form fields. - Fields the admin form offered were silently dropped.
effective_date(rendered into the policy text),requires_consentandidentifierhad no columns. They are added;getByIdentifier(), which died on a SQL error, works and matches the published status the workflow sets. The effective date is converted from the admin's own date format (the form posts25/10/2026for a French admin,01.10.26for a German one) before it is stored. - The privacy-policy sentence in the cookie banner is shown in the store view's language while it is still the shipped default.
- Save and Continue Edit on the policy form returned to the grid.
- The seeded privacy-policy cookies stop writing fields the table has no column for.
AddPrivacyPolicyCookieset a purpose and a sort order that no column exists for and spelled the active flagis_enabled;AddPolicyVersionCookiepassed a sort order the same way. Magento's save drops such fields without a word, and a seed patch runs once on an install nobody watches, so none of it ever surfaced. Nothing was lost — neither invented field has a column andis_activedefaults to true, confirmed against the seeded rows on a real install — so there is nothing to backfill. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Added
- Translations. Every phrase is translated in
en_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review.
Known limitations
requires_consentis now stored, but no storefront path reads it yet: every published policy is presented for acceptance, as before. The admin toggle defaults to on, which is how every policy has behaved.- Save & Duplicate is not implemented by the save controller; it saves and returns to the grid.
Upgrade notes
- Schema:
requires_consent,effective_dateandidentifieronqoliber_gdpr_privacy_policy; onqoliber_gdpr_policy_consent,policy_idbecomes nullable andcms_page_id,policy_type,customer_email,consent_methodandcountry_codeare added. TheBackfillPrivacyPolicyIdentifierdata patch gives existing policies an identifier from their title and keeps their last-updated date. Runbin/magento setup:upgrade, and in production modesetup:di:compileandsetup:static-content:deploy. - Shoppers are asked to accept once more. In database mode the acceptance cookie now names the policy, so a cookie set by 1.3 no longer matches; customers whose latest record is a decline or withdrawal are asked again too. In CMS-page mode on a multi-language shop, shoppers on a store view whose page differs from the first page with that URL key are asked once more, because their stored version named the other page.
- Review the store view of every policy saved through the admin before this release — each was stored for store view 1, whatever was chosen. Nothing can recover the intended store view automatically.
- Store view 1 may switch policy on upgrade. A policy published from the admin before this release sits on store view 1, and a store view's own policy now wins over the All Store Views one — so store view 1 starts showing it, and logged-in customers there are asked to accept it, since no acceptance is recorded against its id. Correct the store view (or archive the policy) before upgrading if that is not intended.
- Custom code posting to
gdpr/consent/logmust send acontext;cookie_consentis refused. qoliber/gdpr-policy-hyva1.4.0 requires this release.- Constructors changed:
ViewModel\PrivacyPolicyandModel\Renderer\CmsPageRendereruseGetPageByIdentifierInterfaceinstead of the page factory;Model\Renderer\VersionBasedRendererno longer takes a search criteria builder;Plugin\ViewModel\CookieConsentPlugingains thePrivacyPolicyview model;Controller\Adminhtml\PrivacyPolicy\Savegains a date filter.
Qoliber_GdprPolicyHyva
Fixed
- A policy answer is evidence first, a cookie second. Hyvä accepted the privacy policy by setting a browser cookie and nothing else. It now posts the answer to
gdpr/policy/accept, and remembers it — and closes the modal — only once the server confirms it was recorded; otherwise the modal stays open with an error. - Decline. The modal had no way to decline; it offers Decline, recorded like an acceptance.
- The store view's own policy. The policy was fetched from the site root, which on a store view served under a path (
/de/) is another store view's policy. The policy and the answer now go through the store view's own base URL. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Upgrade notes
- Requires
qoliber/gdpr-policy^1.4 (raised from ^1.2). Its 1.3 endpoint refuses acceptances in CMS-page mode, where this modal would then never close. Update both together, or runcomposer update qoliber/gdpr-metapackage -W.
Qoliber_GdprPrivacyCenter
Fixed
- The privacy dashboard's data categories are shown in the store view's language. Their names now come translated from
qoliber/gdpr-data-subject1.4; the template passed them through__()as computed strings, which no translation collector can see. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Added
- Translations. Every phrase is translated in
en_US,de_DE,pl_PL,fr_FRandes_ES. French and Spanish are machine-quality and awaiting native review.
Upgrade notes
- Requires
qoliber/gdpr-data-subject^1.4 (raised from ^1.2); with an older one the category names would stay English on every store view.
Qoliber_GdprPrivacyCenterHyva
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - No functional change in this module. Its code is identical to 1.3.0. The substance of 1.4.0 is multi-store and multi-language support and the evidence behind it: consent wording, visibility and evidence per store view and website (
qoliber/gdpr-consent), privacy policies per store view with acceptance recorded on the server (qoliber/gdpr-policy,qoliber/gdpr-policy-hyva), a cookie banner in every store view's language with every decision on record (qoliber/gdpr-cookie,qoliber/gdpr-cookie-hyva), data-subject retention and erasure fixes (qoliber/gdpr-data-subject), and every phrase translated into German, Polish, French and Spanish.
Fixed
- The Hyvä theme requirement is bounded. It was
"*", which resolves whatever is newest — including a major release this module has never been run against. It now requires^1.3.11, the same floor as its sibling Hyvä companions. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Qoliber_GdprWithdrawal
Fixed
- The withdrawal button speaks the store view's language. While its label is the shipped default — the Directive's English wording, "Withdraw from contract here" — it is shown in the store view's language (German "Vertrag hier widerrufen", Polish "Odstąp od umowy tutaj"). A label the merchant configured is shown as typed.
- Broken translation rows. Three German and three Polish rows held an unescaped quote inside a quoted field; Magento's CSV reader moved it, and shoppers saw text such as
„%1" nie istnieje.". They use the language's typographic quotes. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Added
- Translations. French and Spanish added; German and Polish complete. French and Spanish are machine-quality and awaiting native review.
Known limitations
- The Polish wording of this module addresses the shopper formally ("Pan/Pani"); the rest of the suite uses the informal register. Left as it is pending native review.
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules.
Qoliber_GdprWithdrawalGraphQl
Fixed
- The German and Polish "Order "%1" was not found." error held an unescaped quote that Magento's CSV reader mangled; it uses typographic quotes.
- A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Added
- Translations. French and Spanish added; German and Polish complete. French and Spanish are machine-quality and awaiting native review.
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - No other change in this module. Its PHP and schema are identical to 1.3.0.
Qoliber_GdprWithdrawalHyva
Added
- Translations. French and Spanish added; German and Polish complete. French and Spanish are machine-quality and awaiting native review.
Known limitations
- The Polish wording of this module addresses the shopper formally, like
qoliber/gdpr-withdrawal; the rest of the suite uses the informal register. Left as it is pending native review.
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - No other change in this module. Its templates and configuration are identical to 1.3.0.
Fixed
- A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
Qoliber_GdprYireoGtm
Added
- Translations. The module shipped none. Every phrase it shows is now translated in
en_US,de_DE,pl_PL,fr_FRandes_ES, in this module, so it is translated when installed on its own. French and Spanish are machine-quality and awaiting native review.
Changed
- Version aligned with the suite-wide 1.4.0 release;
qoliber/gdpr-metapackagerequires^1.4for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules. - The consent bridge to Yireo's container is rewritten around the Consent Mode API (see Fixed). Configuration is unchanged; nothing to reconfigure.
Fixed
- Google Consent Mode was never actually set. The bridge pushed named
consent_default,consent_updateandyireo_consent_updateevents onto the dataLayer and calledgtag('consent', …)nowhere. A named event carries no consent state, so the tags in Yireo's container ran as though Consent Mode were not in use: a shopper who refused everything was still measured and still had advertising cookies written. The state is now set throughgtag('consent', 'default', …)before Yireo's container loads andgtag('consent', 'update', …)on every change. All three named events are still pushed for containers built on them before 1.4 — as a notification alongside the state, never instead of it. - The signals follow the categories the shopper allowed. Defaults come from the store view's own category-to-signal map in
qoliber/gdpr-cookie1.4.0 (Model\GoogleConsent\SignalMap), sofunctionality_storagestarts denied and is granted only by the Functionality category, and onlysecurity_storageis granted before a shopper has chosen. - The bootstrap survives a strict CSP. The inline script is registered with Hyvä's
registerInlineScript()where Hyvä is present and rendered throughSecureHtmlRendererotherwise; unregistered, a strict policy blocked it and with it the whole Consent Mode setup, while the banner carried on as if all were well. - A released tag now contains what the tag says.
composer.jsonpinned its ownsource.referencetomain, and a package's manifest wins over the tag in Composer's VCS resolution, so Private Packagist served this package's 1.3.0 with whatevermainheld at the time — an install pinned to^1.3could receive unreleased code with nothing to show for it. The pin is gone; the reference comes from the tag, and the manifest no longer states aversioneither — the same mutable-file-over-immutable-tag mistake one step further on, and what madecomposer validate --strictfail.
1.3.0 — 2026-09-01
Qoliber_GdprAdmin
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprConsent
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change since 1.2.1. Everything this module ships was released there, including the
support.securityadvisory channel; this release only moves the version so the suite is in lockstep.
Qoliber_GdprConsentHyva
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change since 1.2.1. Everything this module ships was released there, including the
support.securityadvisory channel; this release only moves the version so the suite is in lockstep.
Qoliber_GdprCookie
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprCookieHyva
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprCookieTemplates
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprCore
Added
Test\Unit\Outbox\OutboxContractParityTest— drives the data-subject and withdrawal email workers through the same eight concurrency scenarios and compares observable behaviour: the queries issued and the row state written, never source text or private method names. The two implementations stay module-local so neither module is forced onto a lockstep core dependency, and this suite is what keeps them from diverging. Proven by regressing either worker to the other's bug, which fails the suite naming that worker specifically.Test\Unit\Schema\ResourceModelIdentityTest— asserts every resource model's declared primary key matches the column its table actually uses. Written after a retention job grouped on a column it believed was the primary key, was not, and silently purged nothing.
Changed
TESTS.mdre-measured. Its counts were from the 1.2.0 run and had drifted — 912 unit, 69 integration and 547 end-to-end tests now, across 22 Playwright projects rather than 20. Addstests/tools/gate.sh, which collapses the unit, phpcs and PHPStan stages into one command with one exit status, and records whyconfig-policyandconfig-toggleswere split out ofconfig-effects.
Note
- No runtime change in this module. Its public API is identical to 1.2.0; what this release adds is test coverage and packaging metadata. The version is 1.3.0 rather than a patch because the GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board. The substance of 1.3.0 is inqoliber/gdpr-data-subjectandqoliber/gdpr-withdrawal; the parity suite added here is what holds their two module-local outbox implementations to identical behaviour.
Qoliber_GdprDataSubject
Fixed
- Two cron nodes processed the same subject-access request. Requests were claimed by reading a row and then writing
processing— two workers could both readconfirmedand both proceed, so the subject received two exports and two emails, and the second run overwrote the first's files. Claiming is now a conditionalUPDATE ... WHERE status = ?honoured only when it affects exactly one row, and the claim returns the lease generation the rest of the attempt is fenced against. - A request whose worker crashed stayed
processingforever. Onlyconfirmedrows were ever selected, so nothing reclaimed a stranded row and the request silently missed its Article 12(3) deadline. Expired leases are now recovered at the start of each run, restored to the status they were claimed from, and bounded to this worker's own request types so a blanket recovery cannot reclassify an unrelated deletion or anonymisation job. - A total data-fetch failure was recorded as "no data found". Every fetcher exception was caught and the loop continued, so when all of them failed the result was an empty array — indistinguishable from a subject we genuinely hold nothing about. The request was closed as
completedand the subject was told there was no data. Collection now returns an explicit outcome: data ready, verified empty, retryable, or terminal. "We hold nothing about you" is only ever reported when every enabled fetcher actually ran and returned nothing. - A partial export was published as complete. Each format conversion was individually wrapped in a try/catch, so a format that failed simply vanished from the snapshot and the subject received an export quietly missing part of their data. Conversion and staging are now all-or-nothing: any failure discards the whole attempt and retries.
- The download controller served live data in place of the export. It read
response_data['data_files'][$format]and fell through to a fresh database collection whenever that key was absent — a format never generated, a verified-empty result, a truncated snapshot. The subject then received current data under the guise of the point-in-time export they had requested, which is a different answer to the Article 15 question they asked. Downloads now resolve only through the committed snapshot, and the legacy live path is reachable only for a pre-1.3.0 row that recorded nothing at all. - Exports were deleted while still referenced. Cleanup removed any
gdpr_exportsdirectory older than the retention window on modification time alone, so a subject who had been notified but had not downloaded within 72 hours lost their export. The sweep is now reference-aware, keeps a grace period for files staged by an in-flight attempt, and is cursor-paginated so a large backlog does not exhaust memory. - The access worker ignored legal holds. It had no
is_blockedfilter at all, so a request placed under hold was still exported and emailed. The hold is now enforced three times over: in the selection, inside the claim, and again at commit time — a hold placed while an attempt is running stops it from publishing. - A guest export could omit an entire store's data. The worker searched only the store view the request was submitted through, while the customer path already searched the whole website. A guest who ordered through two store views of one website had data in both and received one.
- Notification failures were indistinguishable from export failures. A request whose export succeeded but whose email could not be delivered was recorded the same way as one that produced nothing, so the operator could not tell which subjects were owed a resend and which needed the export re-run.
- The outbox worker now closes the request it delivered for. The fetch worker hands delivery to the outbox and leaves the request at
notification_pending; only a delivered message may move it tonotified.ProcessEmailQueuedid not touch the request row at all, so a completed DSAR stayed atnotification_pending— and the access and download gates acceptnotified/accessedonly. The subject received a link that every gate rejected. Delivery and the request transition now happen in one transaction, so neither can land without the other, and an exhausted delivery recordsnotification_failedagainst a request whose export is still intact and resendable. - The notification was sent from the wrong store's configuration. The outbox resolves the data-ready template and the sender identity, and it runs in cron — where there is no current store, so an unscoped lookup returns the CLI default. A merchant running several brands sent every subject the default store's template from the default store's sender, and nothing about the resulting email looked wrong from the inside. The DSAR worker now stamps
store_idinto the queued payload, and the outbox resolves both template and sender at that store, falling back to the request row for anything queued earlier. - A recovered worker could close a newer worker's delivery. The queue close matched on
status = processingalone, so a worker whose lease had already expired and been reclaimed would close the claim of the worker that had since taken over and was still sending — marking the message delivered mid-flight and moving the request tonotifiedon the strength of an abandoned attempt. The close is now fenced to the lease generation it claimed under, the same ABA guard the request lease already carried. - Duplicate notification emails. The outbox accepted the same logical message more than once; enqueueing is now deduplicated on a key and happens in the same transaction that publishes the snapshot, so an export can never be committed without its notification or a notification queued for an export that rolled back.
Added
- Attempt-scoped export storage. Files are staged under
{requestId}/attempt-{n}/and become reachable only when the transaction committing the snapshot that names them succeeds. A fenced or rolled-back attempt therefore leaves nothing servable behind, and the sweeper collects it. notification_pendingandnotification_failedstatuses, so "the export is ready and the subject has not been told yet" and "we produced an export and could not deliver it" are distinct from a failed export.- Request processing metadata —
attempt_count,processing_started_at,next_attempt_at,last_error_codeandclaim_origin_status— with covering indexes. Every column is additive and nullable or defaulted, so a 1.2.x worker ignores them and rollback stays available during the deployment window. - A Processing column in the request grid. The lifecycle columns above were written by the workers and read by nothing, so a request could exhaust its attempts and sit at
failedwith the reason recorded in a column no screen displayed. The column shows terminal failure with its error code, a pending retry with its budget and next attempt time, a held lease with when it was claimed, and — for a request that succeeded only after failing —Recovered, which is the only place an intermittently failing fetcher is visible at all. - Bounded retries with backoff. Attempts are counted at the claim, not at the catch, so a worker that crashes mid-run consumes an attempt instead of retrying forever.
Deployment
- Export storage must be shared across application nodes. The default
ExportStorageInterfaceimplementation writes encrypted exports undervar/gdpr_exports/{requestId}/attempt-{n}/. The worker that generates an export and the web node that later serves the download are frequently not the same machine, and the retention sweep must see every node's files to decide which are unreferenced. On a multi-node deploymentvar/gdpr_exportstherefore has to be shared storage (NFS, EFS or equivalent) — orExportStorageInterfacebound to an implementation backed by object storage. Without one of those, a subject's download resolves only on the node that generated it and 404s everywhere else, and the sweeper cannot see files it should be protecting. Single-node installs need no action. - Run
bin/magento setup:upgradeafter updating. Every new column is additive and nullable or defaulted, so a 1.2.x worker ignores them and rollback stays available during the deployment window.
Removed
Cron\ProcessDataAccessNotifications. Superseded byProcessDataFetchRequestsin 1.1.0 and left behind: nocrontab.xmlentry referenced it, and its selection —request_type = fetch_datawithstatus = pending— is a pair nothing produces, since requests are createdpending_confirmationand verification promotes them straight toconfirmed. It was also unsafe if anyone had scheduled it, claiming nothing and therefore racing the live worker, recording a total fetcher failure as "no data found", marking a requestnotifiedeven when the send threw, and acting on requests whose subject never completed the double-opt-in confirmation.
Qoliber_GdprDataSubjectHyva
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprDemo
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprEmbed
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprFrontend
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprFrontendHyva
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprGtm
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprPolicy
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprPolicyHyva
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprPrivacyCenter
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprPrivacyCenterHyva
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprWithdrawal
Fixed
- Two cron nodes could send the same withdrawal email. Rows were claimed by reading them and then writing
processing, under a comment asserting this "prevents concurrent cron runs from picking up the same row". It does not: two nodes can both readpending, both writeprocessing, and both then send. Claiming is now a conditionalUPDATEhonoured only when it affects exactly one row. - A row stranded by a crash was never retried. Only
pendingwas ever selected, so a worker that died mid-send left the row atprocessingpermanently and the recipient never heard back. Expired leases are now returned before each run. - Retries had no backoff. A failing row was retried on every cron tick.
next_attempt_atnow defers retries by 5, 10 and 20 minutes, and the selection honours it, so the delay is enforced rather than merely recorded. - Terminal states were written unconditionally, so a recovered worker could overwrite a newer claim. Finalisation went through a model save, which writes regardless of who owns the row — and a full model save clobbers every other column with its stale copy at the same time. Every terminal transition is now a conditional
UPDATEfenced to the lease generation the worker claimed under, and a refused close is logged rather than silently applied. - The sender identity was resolved at default scope. The template was already store-scoped, but
setFromByScope()was called without one, so a multi-store install sent from the default store's identity regardless of where the request originated. error_messagepersisted the exception's file and line. Those change with every release, making them useless for correlating a failure across versions, and they published internal filesystem paths into a table nothing purges. Replaced with the exception class, mirrored into a stablelast_error_code.
Added
processing_started_at,next_attempt_atandlast_error_codeon the email queue, with covering indexes. Strictly additive and nullable, so a 1.2.x worker ignores them and rollback stays available.
Notes
- No deduplication key here, deliberately. A withdrawal documents confirmation on every transition to
confirmed, so repeated events of one type are legitimate and(withdrawal_id, email_type)would be the wrong key. A correct key needs an event-generation identifier, which is data-subject work. - The claiming and recovery implementations stay module-local rather than moving into
qoliber/gdpr-core. Sharing them would force this module andqoliber/gdpr-data-subjectonto^1.3of core and make them lockstep consumers for roughly forty lines ofUPDATE ... WHERE. Behaviour is held identical by a parity suite in core instead.
Qoliber_GdprWithdrawalGraphQl
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprWithdrawalHyva
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
Qoliber_GdprYireoGtm
Changed
- Version aligned with the suite-wide 1.3.0 release. The GDPR Suite is now versioned in lockstep, so every package carries the same number and
qoliber/gdpr-metapackagerequires^1.3across the board — installing "GDPR Suite 1.3.0" can no longer resolve a mix of old and new modules. - No functional change in this module. Its code is identical to 1.2.0; the release exists so the suite version is coherent. The substance of 1.3.0 is in
qoliber/gdpr-data-subject(the DSAR lifecycle rework) andqoliber/gdpr-withdrawal(the email outbox concurrency contract).
Added
support.securityincomposer.json, pointing at<https://security.qoliber.com>, so Composer and Packagist surface the advisory channel for this package. Added suite-wide in the 1.2.x line but never released for this module, which stayed on 1.2.0.
1.2.1 — 2026-08-21
Qoliber_GdprConsent
Fixed
- Anonymous consent-log rows were still at risk from the purge. Guests are
recorded with NULL for both
customer_idandcustomer_email(the controller deliberately does not invent an address), so every anonymous row shared one all-NULL group. Keeping "the newest" of that group would have preserved one arbitrary guest's row and deleted every other guest's. The keep-set is now restricted to rows that actually identify a subject, so expired anonymous rows are purged wholesale — there is no subject whose current state they represent. ConsentLogresource model named a column that does not exist. It declaredlog_idas the identity ofqoliber_gdpr_consent_log, whose primary key isconsent_id; the collection carried the same wrong$_idFieldName. Inserts worked, which is why it went unnoticed, but every load, save and delete by id targeted a phantom column. A new suite-wide test now checks every resource model and collection against the declared schema.- Consent-log retention deleted nothing from
qoliber_gdpr_consent_log. That table's primary key ISconsent_id— its own identity column, not a reference to a consent definition — and the purge grouped by['customer_id', 'consent_id']. Every row therefore formed a group of one, every row counted as "the newest" and was kept, so the cron reported success while retention did nothing at all. The grouping now excludes the table's own primary key, and includes the email column so guests (who have nocustomer_id) are grouped by their own identity rather than collapsing into a single NULL group and losing all but one row. Storage limitation under Art. 5(1)(e) was not being applied to this table on any install running 1.2.0. - Newsletter signup returned HTTP 500 on any store whose newsletter controller answers with JSON.
Magento\Newsletter\Controller\Subscriber\NewAction::execute()declares no return type — only a@return Redirectdocblock — so a module may legally override the controller and return something else, and an AJAX subscribe form returningResult\Jsonis the ordinary case on a Hyvä storefront.NewActionPlugin::afterExecute()type-hintedRedirect, turning a supported customisation into a fatalTypeErroron every subscription attempt. Reported from a live store. - The same defect sat one step behind in
Manage\SavePlugin, which hintedResponseInterface.Result\JsonimplementsResultInterface— an unrelated hierarchy — so a JSON result would have failed there too. Neither plugin reads the result; both only pass it through, so both now accept and returnmixedand constrain nothing.
Added
- Security advisory feed declared.
composer.jsonnow carries asupport.securitypointer to https://security.qoliber.com, so Composer and package consumers can find advisories for this package. Metadata only; no code or dependency change. - Unit and integration cover for both plugins, which previously had none at all. The integration test builds the plugins through the ObjectManager with their real collaborators and passes a real
Result\Json— an intercepted subclass, so the object under test is the sameJson\Interceptornamed in the customer's stack trace — and pins that both plugins are still registered on their controllers, which a unit test cannot see. Restoring the old type hint fails that suite with the reported error verbatim.
Note
Neither setup:di:compile, production mode, nor a newer PHP would surface this: the interceptor generates correctly and a declared-parameter TypeError behaves identically on PHP 8.1 through 8.5. Only a test passing a foreign result type catches it.
Qoliber_GdprConsentHyva
Fixed
- An empty "Privacy and Consent" section rendered in checkout when no checkout-scoped consents were configured. The layout emitted the wrapper, the legend and the messenger unconditionally while only the inner template checked for consent definitions, so a store with none got a titled, empty box. Reported from a live store. The wrapper is not the Magewire block and so has no
$magewireto consult; it now uses aCheckoutConsentsAvailabilityview model that mirrors the Magewire component's filter exactly, so the two cannot disagree about whether anything will render. It deliberately fails open — if the lookup throws, the section still renders, because an empty box is a far smaller problem than silently dropping a required consent out of checkout. - The consent JavaScript could take the Hyvä newsletter form down entirely. On a store running a third-party newsletter module the subscribe button did nothing and the console filled with
Alpine Expression Error: Cannot read properties of undefined (reading 'classList'). Every dereference on that path is now guarded:showConsentError/clearConsentErrortolerate a missing checkbox;validateRequiredConsents/attachConsentsToFormno-op without a bound form (they stay reachable from outside the component viaform._x_dataStack, whereinit()'s own guard never applied);validateConsentstolerates$elbeing absent; and the.closest('.field')chains no longer assume a match. - Most importantly, the
window.initNewsletterFormandwindow.initContactFormwrappers no longer wrap a factory that does not return asubmitForm. Previously, when another module owned that factory, we replaced its working submit handler with one that threw on every submit — breaking the very form we were extending. This is the most likely mechanism behind the reported console errors.
Added
- Security advisory feed declared.
composer.jsonnow carries asupport.securitypointer to https://security.qoliber.com, so Composer and package consumers can find advisories for this package. Metadata only; no code or dependency change. - Unit cover for the checkout availability view model, and a Hyvä end-to-end spec that drives the shipped component in a real browser and asserts the storefront raises no uncaught Alpine errors. Both guard sets are mutation-proven: removing either guard fails the suite.
1.2.0 — 2026-08-19
Qoliber_GdprAdmin
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released.
Fixed
- The Hyvä cookie banner was absent on a fresh install.
qoliber_gdpr/general/enabledhad noconfig.xmldefault, andQoliber_GdprCookieHyvagates all six of its storefront blocks on it viaifconfig. Magento resolves an unset path as false, so a clean install rendered no banner, no consent UI and no blocker until an admin opened the config section and pressed Save — which is what wrote the row, and why it looked correct on every developer machine.
Removed
- Dead settings that rendered, saved, and were read by nothing:
general/data_retention_period, the entirerequestsgroup (auto_approve_access,request_expiry_days,deletion_grace_period), the entireautomationgroup (auto_delete_inactive,inactive_period_days,auto_anonymize_orders,order_anonymize_days) and the entirenotificationsgroup (notify_admin_requests,admin_notification_email). The automation group is the one worth calling out: it offered "Auto-delete Inactive Accounts / after N days", an Art. 5(1)(e) storage-limitation control with no implementation behind it, and its twoconfig.xmldefaults sat under adata_retentionnode thatsystem.xmlcallsautomation, so they never bound either. - The orphaned cookie-detail form assets (
cookie-detail-form.js, its template and LESS), left behind when the cookie-template picker was deferred.
Qoliber_GdprConsent
Changed
- The consent-list block now declares its customer session and log-collection factory as dependencies. Both were fetched from the
ObjectManagerservice locator inside methods, which hid them from DI: no other module or test could substitute them, and the block could not be constructed without the global object manager. They are now optional trailing constructor arguments, which keeps appending them backward compatible for any theme subclass that does not forward them — those still fall back to the locator. The session is wired as aProxyinetc/frontend/di.xmlon purpose: the block is attached indefault.xmland so renders on cacheable pages, and constructing the real session eagerly would start a session on every render and defeat full page cache for anonymous visitors. - Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released.
Added
qoliber_gdpr_consent/retention/consent_log_daysand the nightly purge that makes it real. The consent evidence tables hold a guest email, an IP and a full user agent per row, and nothing ever deleted them — so a subject who exercised Art. 17 still had their address in the GDPR module's own tables afterwards, indefinitely. A setting of this name was removed earlier in 1.2.0 precisely because it was declared, saved and read by nothing; it returns withCron\PurgeConsentLogsbehind it and unit tests that fail if it becomes dead again. It defaults to 0 (keep indefinitely): an entry is simultaneously personal data and the proof that consent was given, so the retention period is the controller's decision, not a default this extension should pick. The newest entry per customer and consent is never purged.qoliber_gdpr/consent/checkout_enablednow has an admin field.Model\Checkout\ConsentConfigProviderhas always read this path, but nosystem.xmldeclared it, so its value came fromconfig.xmland no merchant could change it. Declared with aconfig_pathoverride so the stored path is unchanged and existing installs do not reset.- Integration coverage for the registration boundary: consent evidence is asserted through the real
CustomerRegisterSuccessobserver payload, so a registration form that dropped the consent field entirely now fails a test.
Fixed
- Full page cache served one customer's consent state to every other customer. The consent list is attached to
form.subscribeindefault.xml— the newsletter form in the footer of every storefront page — and pre-ticked its boxes from the logged-in customer's own consent history. FPC varies only oncustomer_logged_inandcustomer_group, never on customer id, so the first shopper to warm a page baked their ticked box into the cached HTML for everyone else in that group. Submitting that form then wrote a consent-log row recording an acceptance they never gave: a fabricated consent record, produced by the module whose purpose is proving consent. Pre-filling is now opt-in per placement and enabled only onnewsletter_manage_index, which core markscacheable="false".cacheable="false"was deliberately NOT used on the footer placement — that block is on every page, so it would have disabled full page cache store-wide. - Consent IP minimisation now delegates to the shared
Qoliber\GdprCore\Api\IpMinimizerInterface, so the Art. 5(1)(c) truncation policy cannot drift between modules — this module andGdprPolicyeach carried a private copy. - A customer whose email is null no longer aborts account creation.
CustomerInterface::getEmail()is nullable whileConsentLinkServiceis strict-typed, so the raw value raised aTypeError— an\Error, which the observer'scatch (\Exception)cannot catch.
Removed
- The duplicate
qoliber_gdpr_consent/consent/checkout_enableddefault, which no code read.
Qoliber_GdprConsentHyva
Fixed
- Checkout consent was recorded against the wrong definitions. Consents were keyed by loop position, and those keys travel to
ConsentTracker, which treats a numeric identifier as a database primary key — so ticking the first checkout consent recorded consent against whichever definition happened to hold id 1. Now keyed by the definition's own code, as Luma does. - Ticking a checkout consent persisted nothing. Only "accept all" and "reject all" wrote the checkout session, and the method meant to emit the data built its payload and then discarded it — so a shopper who ticked the boxes and placed the order produced no consent rows. Every change now persists.
- A sixth required checkout consent was invisible on Hyvä. The query carried a
setPageSize(5)"for safety"; the Luma path has no cap. Removed. - Consent boxes were pre-ticked from a previous form. The checked state was overridden from
localStorage, carried across unrelated forms, so a consent ticked once on the newsletter arrived pre-ticked on registration. It could only ever tick: the writer stored'1'for true but removed the key for false, so'0'was never written and the loader could never pre-untick. A pre-ticked box is not consent under Art. 4(11), so the override is gone rather than corrected — the server-rendered state is authoritative. Luma never had this behaviour.
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprCookie
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released.
Fixed
composer require qoliber/gdpr-cookieon its own returned HTTP 500 on every storefront page.getReopenerType(),getDisplayStyle()andgetTheme()are declared: stringunderstrict_typesand returnedscopeConfig->getValue()with no fallback, while those defaults ship only inQoliber_GdprFrontend— which this module does not require. The footer link block has noifconfig, so it renders on every page:getValue()returned null, null from a: stringis aTypeError, andLayout::renderNonCachedElement()catches\Exceptionrather than\Error. All three now cast with a real default.- The banner rendered with no copy on that same install. Every
qoliber_gdpr_cookie/texts/*value was read with no fallback, so a store withoutQoliber_GdprFrontendgot a banner with no title, no message and no policy text, silently. The copy now falls back to the translated defaultsgetTexts()already held. - Scripts the merchant deferred pending consent were never activated. The documented way to gate a third-party script in this suite is to author its tag with
type="text/plain"and adata-consent-category, and let the consent component swap it for a live script once that category is allowed. That swap lived only injs/view/cookie-consent.js, which carries a requirejs alias but is initialised by no template — the Luma banner mountsjs/view/privacy-preferences.js, whose owntoggleScripts()only fired jQuery events. So the swap was dead code: the shopper consented and the merchant's script still never ran. This is a functional break rather than a privacy gap. The activation now lives in the component that is actually mounted; the publishedqoliberGdpr*EnabledjQuery events are kept alongside it, since a merchant may already listen to them. - The Luma cookie banner was absent on a standalone install. This module gates its banner on
qoliber_gdpr_cookie/consent/enabledand its detector ondetection/client_side_enabled, but both defaults shipped only inQoliber_GdprFrontend, which this module does not require and deliberately does not sequence (the reverse direction would be circular).composer require qoliber/gdpr-cookieon its own therefore produced a store with no cookie banner at all, silently. Both defaults, plusqoliber_gdpr/general/enabled, now ship here.
Added
- The Luma consent component now listens for
qoliberGdprEmbedAllowCategory, so the "Always allow<provider>" button on a blocked third-party embed (seeQoliber_GdprEmbed) grants that provider's cookie category through this module's existing consent path. Embed consent and banner consent therefore share one writer rather than diverging.
Removed
- The orphaned Import Cookie button from the cookie-detail form. It emitted an
importCookieevent with no handler, so pressing it did nothing; the feature is deferred rather than half-present.
Qoliber_GdprCookieHyva
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released.
Fixed
- Google Consent Mode never loaded on Hyvä, so analytics and ads stayed dark after consent. The signal the tag loaders consume —
qoliber_gdpr_google_consentin localStorage plus theqoliberGoogleConsentUpdatedevent — was written only by a RequireJS module the Luma banner pulls in, and Hyvä layouts remove that banner. Nothing emitted it, soQoliber_GdprFrontendHyva,Qoliber_GdprGtmandQoliber_GdprYireoGtmall waited forever. It failed silently, because the Hyvä template defines agtag()dataLayer stub: consent updates were pushed to an array no library ever read. The call was also guarded ongtagalready existing, which was circular — the loaders wait for this signal before loading gtag. Fixing that exposed a second divergence: Hyvä hardcoded onlyanalyticsandmarketingand ignored thegoogle_servicescategory, so the same "Allow All" grantedanalytics_storageon Luma and denied it on Hyvä. The Luma category mapping is now mirrored exactly, and a spec asserts the two themes' payloads are identical to each other. - "Do Not Sell or Share My Personal Information" was inert on Hyvä. The layout mounted the Luma template, whose only handler wiring is
text/x-magento-init— RequireJS, which Hyvä does not ship. The CCPA/CPRA control rendered and did nothing: clicking it jumped to the top of the page and recorded no opt-out. Hyvä now has its own template that dispatches to the consent component, which owns the decision. - A Global Privacy Control signal was ignored on Hyvä.
window.qoliberGdprComplianceis emitted on both themes but was read only by the Luma cookie-manager, so the US opt-out model was not implemented on Hyvä at all — a legal requirement in California, Colorado and Connecticut.applyGpcIfPresent()now runs before the banner decision, treatingnavigator.globalPrivacyControlas authoritative (it is never page-cached) and never overriding a choice the shopper already made. - Banner decisions left no server-side record on Hyvä. Luma POSTs every save to the consent-log endpoint, which allow-lists the
cookie_categoriesscope for exactly that. The Hyvä component wrote browser storage only, so a Hyvä store had zero Art. 7(1) evidence for banner decisions. It now posts the same payload, fire-and-forget so a failed audit write can never block or reverse the shopper's choice. - The consent cookie was never written on Hyvä, and the server-side cookie blocker was therefore dead there. This module forces
COOKIE_CONFIG.cookie_restriction_enabled = true, which arms Hyvä's own gate:hyva.setCookie()silently drops any name absent fromcookie_consent_config, parks it in temp storage and returns as though it had written.gdpr_consentwas never registered —gdpr_policy_versionwas, so the mechanism was understood — so the cookie never reacheddocument.cookie. That is not only a storage inconsistency with Luma:Qoliber_GdprCookie's response-level blocker early-returns when that cookie is absent, so on a Hyvä store view it could never expire a single rejected cookie, leaving the entire HttpOnly / server-set / proxy-set layer unenforced.gdpr_consentis now registered inessentialandnecessary— recording that a shopper refused cookies cannot itself require consent. - Deferred scripts are now activated on Hyvä. The Hyvä consent component had no equivalent of the Luma script activation at all, so a script correctly deferred with
type="text/plain"and adata-consent-categorystayed inert on a Hyvä store view even after the shopper granted its category.applyConsent()now performs the same swap, so it fires on init, accept-all, accept-selected, decline and the embed "always allow" path. Note for anyone editing this template: the method's documentation deliberately does not spell out a literal script tag, because the comment lives inside an inline script that Hyvä hashes for CSP viaregisterInlineScript()— a literal tag in the body corrupts the content that is hashed, the hash stops matching, and the browser blocks the entire cookie banner. - The compliance model (
qoliber_gdpr_cookie/compliance/*) is now emitted on the Hyvä storefront through a dedicatedcompliance-config.phtml, so US opt-out mode, the Global Privacy Control signal and the Do-Not-Sell link behave on Hyvä as they do on Luma.
Added
- The Hyvä consent component now listens for
qoliberGdprEmbedAllowCategory, so the "Always allow<provider>" button on a blocked third-party embed (seeQoliber_GdprEmbed) grants that provider's cookie category through this module's own Alpine consent path. Without it the button was a silent no-op on Hyvä, because the Luma component that previously carried the listener is removed from Hyvä layouts entirely.
Qoliber_GdprCookieTemplates
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprCore
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released.
Added
TESTS.md— one page describing every automated check that guards the suite: the 693 unit, 63 integration and 537 end-to-end tests, the three gating scripts, and how the PHP floor and ceiling are proven. It also records the traps that cost real debugging time — that unit and integration must not share a process, that the integration sandbox keeps its owngenerated/, why the Playwright project order is load-bearing, and which skips and flakes are expected rather than symptoms.Api\IpMinimizerInterfaceandModel\IpMinimizer— the canonical Art. 5(1)(c) IP-minimisation implementation, shared by the consent and policy modules. IPv4 is truncated to /24 and IPv6 to /48, masked in packed form so the output is canonical RFC 5952; an unparseable address yieldsnullrather than an empty string, because "no address recorded" and "an empty address was recorded" are different facts.
Fixed
getRemoteAddress()returnsfalsedespite its@return stringdocblock, which raised aTypeError— an\Errorthat escapescatch (\Exception)— and broke registration. Cast at every call site, with a regression guard.- A
SearchResultsInterfacereturn-type incompatibility that madesetup:di:compilefatal. Covered by a subprocess test that loads the generated classes, since the failure only appears in a fresh compile.
Qoliber_GdprDataSubject
Fixed
- Stored XSS reaching an authenticated administrator.
request_typecame straight off a storefront POST with no allowlist — the guest controller had one, the logged-in customer controller did not — and was persisted verbatim. The admin grid then rendered it through<bodyTmpl>ui/grid/cells/html</bodyTmpl>, a Knockouthtmlbinding that inserts unescaped, and Magento ships the admin CSP report-only so nothing downstream stopped it. Any registered customer could therefore run script in an admin session.Submitnow allowlists the type against everyDataRequestInterface::TYPE_*constant, and the grid column escapes an unrecognised type as a second layer, because rows written before the allowlist existed are still in the table. - Erasure left most of the request row identifying the subject. The data-request anonymiser replaced
customer_emailalone, leavingip_address,user_agent,response_data,admin_user,blocked_reason— andrequest_data, which is not metadata: a rectification request stores the shopper's own free text, i.e. the names, addresses and phone numbers they asked to have corrected. All are now cleared. - Order addresses kept the subject's real email after erasure.
anonymizeOrderAddress()replaced twelve fields but never calledsetEmail(), so the address survived on every order, invoice, shipment and credit-memo row. The sibling quote anonymiser always did this; the order path simply missed it. - The admin sales grids kept the postal address after erasure.
sales_order_gridhad neither address column written, and the invoice and credit-memo grids were missingshipping_address— only the shipment grid was complete. Street, city and postcode therefore stayed visible and searchable in Sales grids indefinitely. The order'scustomer_note— the checkout comment box, which routinely contains an address or a phone number — is now cleared too. - Quote addresses kept city, postcode and region. Both sibling methods replaced them; this one did not.
- Withheld orders were excluded from an erasure silently. Orders whose status is outside the configured set are skipped, which is correct — they still have to be fulfilled — but nothing recorded it, so the subject was told their data was erased while those orders stayed fully identifiable and the erasure record showed no trace. The withheld increment ids are now listed in the result, matching how unanonymisable quotes were already reported.
- The credit-memo section of a subject-access export ignored store scope. The invoice and shipment fetchers filter on
store_id; this one did not, so it returned rows from every website and disclosed more than the merchant had configured to release. - The export error shown to the shopper quoted the raw exception. Exception text routinely contains the offending value (
Duplicate entry '[email protected]' …), and this string is displayed and stored in the session flash bag. The visitor now gets a neutral message; the scrubbed detail still goes to the log. - Order and newsletter data were exported regardless of configuration.
OrderDataFetcher::isEnabled()andNewsletterDataFetcher::isEnabled()returned an unconditionaltrue, soexport_settings/include_order_dataandinclude_newsletter_datahad no effect and every row was still disclosed in a subject-access export — more personal data than the merchant had configured to release under Art. 15. Both now consult their setting, as the invoice, shipment, credit-memo and address fetchers already did. - Customer addresses were never anonymised.
CustomerAddressDataAnonymizergated onanonymization_settings/anonymize_customer_addresses, a path with no admin field and noconfig.xmldefault, soisSetFlag()returned false on every install and the anonymizer never ran — while the admin comment claimed customer data "and addresses" were covered. The field and its default now exist. - The Privacy Center advertised disabled actions. The Luma dashboard rendered the Anonymize and Delete cards regardless of
account_actions/enable_anonymizationandenable_deletion, while the Hyvä dashboard gated both. The controllers always enforced the flags, so this was a misleading UI rather than an open door; both card blocks now carryifconfig. - The admin was never notified of rectification requests.
gdpr_admin_notificationwas set as a template identifier and registered in noemail_templates.xml, sogetTransport()threw and the caller swallowed it. Registered — in the frontend area, because an adminhtml registration cannot resolvedesign/email/header_templateand the mail arrived with "Error filtering template" as its entire body. - The data subject was never acknowledged.
notification/rectification_confirmation_template, its registered template and its default all existed for a mail nothing sent.RectificationPostnow sends it, in its own try/catch so a merchant-notification failure cannot suppress the subject's acknowledgement. - Notification settings resolved at the wrong store. Both data-ready crons read the template with
SCOPE_STOREand no store id and hardcodedtrans_email/ident_support, ignoringnotification/sender_identityentirely. In a cron the scope resolves to the CLI default, so on a multi-brand install one store's subject-access mail rendered with another's template and sender. Both now take the request's store id. - Personal data reached logs and outlived them. The recipient address and the data subject's address were written raw into
EmailServicelog calls beside a scrubbed exception; andProcessEmailQueuepersisted$e->getMessage()intoemail_queue.error_message. Mail failures routinely quote the recipient, so that stored addresses in a table no retention job clears. Logs are scrubbed; the row now takes a stableQUEUE_PROCESSING_FAILED:<exception-class>code.
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - The subject-access CSV export now passes an explicit
$escapetofputcsv(). PHP 8.4 deprecated calling it without one because the default is changing, and this package's composer constraint claims support up to 8.5 — an Art. 15 export is the last path that should depend on a default shifting underneath it. The value passed is'', which is both the value the default is moving to and the RFC 4180 behaviour: a backslash in a name or an address is now treated as data rather than as an escape character, so exported personal data is no longer mangled.
Removed
- Dead settings that rendered, saved, and were read by nothing:
data_protection_officer/office_hours(its only accessor sat on an interface nothing implements) andretention/consent_log_days(whose group comment promised a nightly cron that never touched consent logs).
Added
anonymization_settings/anonymize_customer_addresses— postal addresses are personal data under Art. 4(1) and are now erasable independently of the account record.
Deprecated
Api\ConfigInterface— restored unchanged after being removed in error during 1.2.0 development. Nothing implements it;DataSubjectConfigInterfaceis the maintained contract. Scheduled for removal in 2.0.0.
Qoliber_GdprDataSubjectHyva
Fixed
- Guest data anonymisation was impossible on Hyvä. The confirm handler called
.then()ondataset.hyvacsp10, which is a string (a Hyvä modal's show-JS, stored in an attribute because Alpine's CSP-friendly build cannot evaluate it), so the click threw aTypeErrorbefore anything ran — and the method it chained to,submitAnonymizationRequest, was defined nowhere in the codebase. The component also declared none of the four state properties the template binds to, so the button looked enabled and no error banner could render. A guest therefore could not exercise Art. 17 on Hyvä at all, silently. The component now carries its state and a real POST mirroring the Luma flow; confirmation uses the browser's own dialog, which is what the CSP build allows.
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprDemo
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprEmbed
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released.
Fixed
- The embed consent log stored the full page URL including its query string. The value arrives from
window.location.href, so search terms and campaign parameters were kept verbatim beside a customer id and a timestamp with no retention job — an indefinitely retained slice of browsing history, and more than Art. 5(1)(c) allows for proving one embed was consented to. Scheme, host and path are now stored; a URL that will not parse is stored empty rather than raw, since the field is written from an untrusted request. - Google reCAPTCHA is no longer mistaken for a Google Maps embed. The Maps provider owns the bare
google.comhost — deliberately, since agoogle.comiframe is a transfer to Google whatever the path — and host matching strips a leadingwww., so it also claimedhttps://www.google.com/recaptcha/.... On any form protected by reCAPTCHA that replaced the captcha with a "Load this content" placeholder, and on a login or checkout form it stopped the customer completing the order. reCAPTCHA is a security and fraud control rather than the marketing transfer this module exists to gate, sogoogle.compaths under/recaptcha/are now exempt. The exemption is boundary-anchored and case-sensitive against Google's own lowercase path, and a URL carrying a..dot-segment is refused an exemption outright, because the browser resolves the segment away before it sends the request —/recaptcha/../maps/embedreads as exempt but fetches the map. No other provider is affected: the hook it uses defaults to exempting nothing. Payment iframes were never at risk and are unchanged. The module matches an allowlist of seven media providers on exact host, so Adyen, Stripe, PayPal, Braintree, Klarna, Amazon Pay, Przelewy24, PayU, Google Pay and 3-D Secure all pass through untouched, and checkout needs no exclusion.
Added
- Script-based embeds are now blocked too, not just iframes. X and Instagram publish no iframe: their embed code is a visible
<blockquote>plus a loader<script>that builds the iframe in the browser. An iframe-only rewriter never saw that transfer, so both providers reached the shopper unblocked despite being configured and enabled. Provider<script src>tags are now rewritten into the same placeholder, anddata-embed-tagrecords which element was removed so consent rebuilds a script rather than pointing an iframe at a.jsfile (it defaults toiframe, so a placeholder already sitting in full page cache still restores correctly). Matching uses the SAME provider allowlist as iframes and resolves the tag's ownsrcattribute, never the text of the markup: a payment SDK, reCAPTCHA, a CDN, an inline script and a JSON-LD block naming a provider are all left byte-identical. That containment is load-bearing rather than cosmetic — a wrongly deferred payment SDK does not degrade to a placeholder, it breaks the checkout — so it is pinned by unit tests and asserted again on a real rendered page, where exactly one of 38 scripts (Luma) and one of 48 (Hyvä) is deferred. - Third-party embed blocking. A YouTube, Vimeo, Google Maps, Spotify, SoundCloud, X (Twitter) or Instagram iframe sets its own cookies and hands the visitor's IP address to the provider the moment it loads — before any click, and before the cookie banner has been answered. Blocking on the play button is too late: the privacy-relevant request already happened when the page rendered. This module intercepts earlier, on the server, rewriting the iframe out of the rendered HTML before it ever reaches the browser. This covers every embed already present in the server-rendered page, including PageBuilder's video content type. It does not cover an iframe a storefront constructs itself in client-side JavaScript after the page has loaded — for example Magento's own product-video gallery (
Magento_ProductVideo), which still reaches YouTube before consent, since there is no HTML for a server-side rewriter to see. - Provider iframes are rewritten server-side into an inert placeholder before the page reaches the browser — on every response, cache hit or miss alike, so a page cached before this module was enabled is still safe. The placeholder shows the provider name and a "Load this content" action instead of the live embed.
- The shopper controls consent at two levels: loading one placeholder restores just that embed for the current session, while allowing the provider's cookie category (via the existing cookie banner) restores every embed from that provider going forward.
- A one-off "Load this content" click is recorded as evidence — with a minimised IP address — satisfying the Art. 7(1) obligation to demonstrate that consent was given. Recorded entries are visible to admins in a new GDPR > Logs & Reports > Embed Consent Log grid.
- Ships with blocking enabled out of the box for YouTube, Vimeo, Google Maps, Spotify, SoundCloud, X (Twitter) and Instagram. Any provider can be excluded from blocking individually under Stores > Configuration > GDPR Compliance > Cookie Consent > Third-Party Embed Blocking, e.g. where another consent tool already manages it.
- "Always allow" now leaves the same demonstrable evidence as a one-off click, on both Luma and Hyvä. Previously only the one-off "Load this content" path wrote an evidence row; the durable "Always allow
<provider>" grant left no server-side trace at all — its only record was localStorage on the shopper's own device, which cannot serve as Art. 7(1) evidence on request.embed-blocker.jsnow posts to the exact same evidence endpoint for that click too, before dispatching the event the cookie banner listens for, so both clicks go through one code path on both themes. A newconsent_scopecolumn (one_off/always, validated server-side) distinguishes the two in the Embed Consent Log grid, which gained a Scope column to show it. - The "Always allow" button is no longer offered when it would be a silent no-op.
qoliber_gdpr_cookie/embeds/enabledhad no relationship to the cookie banner's own master switch (qoliber_gdpr_cookie/consent/enabled): a merchant running this suite for its DSR/policy features alongside a third-party CMP, with the banner disabled, saw a working "Load this content" button next to an "Always allow" button that nothing on the page could ever act on. The placeholder now omits "Always allow" server-side whenever the banner is disabled and keeps "Load this content" either way; the admin config for Third-Party Embed Blocking now also declares a<depends>on the banner's master switch so the coupling is visible rather than folklore. - The placeholder's consent copy is now translatable. The notice text and both button labels were hardcoded English with no
__(), no template override point and no admin field — a real defect for a store that also provisionsdeandfrviews, since Art. 7(2) expects a data subject to be told what they are consenting to in a language they understand. A newApi\PlaceholderCopyInterface, injected intoEmbedRewriter, now supplies the four strings; the Magento-backed implementation runs them through__(), with source keys shipped ini18n/en_US.csv.EmbedRewriteritself stays framework-free and unit-testable with a plain stub of the interface. The provider's own label (e.g. "YouTube") is still interpolated, not translated.
Qoliber_GdprFrontend
Fixed
- The dependency on
Qoliber_GdprCorewas never declared. This module'sApi\TagTypeInterface and Model\TagType\TagTypePoolare BC shims thatextendsthe GdprCore classes of the same name, andetc/di.xmlconfiguresQoliber\GdprCore\Model\TagType\TagTypePooldirectly, socomposer requirecould resolve it withoutqoliber/gdpr-corepresent and the module would fatal on a class that is simply not there. Installing the full suite hid it, because something else pulled the package in. Now required explicitly at^1.2.
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprFrontendHyva
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprGtm
Fixed
- The dependency on
Qoliber_GdprCorewas never declared. This module'sGoogleTagManager tag typeimplementsQoliber\GdprCore\Api\TagTypeInterfaceandetc/di.xmlregisters itself on GdprCore's pool, socomposer requirecould resolve it withoutqoliber/gdpr-corepresent and the module would fatal on a class that is simply not there. Installing the full suite hid it, because something else pulled the package in. Now required explicitly at^1.2.
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprPolicy
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released.
Fixed
- Cross-customer authorisation hole in the policy-consent REST API.
GET /V1/gdpr/policy/consent/checkaccepted a caller-suppliedcustomerIdand answered for it, so an authenticated customer could ask about another subject's policy consent. The endpoint now answers only for the token's own customer. - A broken
:policyTyperoute inwebapi.xmlthat made one endpoint unreachable. - Policy IP minimisation now delegates to the shared
GdprCoreimplementation instead of a private copy.
Added
Model\PolicyConsentSearchResultswith explicit typed overrides, fixing adi:compileincompatibility.
Qoliber_GdprPolicyHyva
Fixed
- The dependency on
Qoliber_GdprConsentwas never declared.Plugin\Model\Consent\Renderer\PrivacyPolicyRendererPlugintype-hintsQoliber\GdprConsent\Api\Data\ConsentDefinitionInterface, so the plugin fatals if that package is absent. Now required explicitly at^1.2.
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprPrivacyCenter
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprPrivacyCenterHyva
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprWithdrawal
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released.
Fixed
- Withdrawal notification templates are now resolved from configuration at send time. The confirmation, approved and rejected templates were selected by hardcoded identifiers, so the admin fields were saved and never consulted.
- Guest withdrawal controllers no longer pass a raw
Throwablein the Monolog context. The message was scrubbed, butNormalizerFormatterre-serialises the exception object, putting the original text — which routinely quotes the subject's email — straight back into the log. - The mail-send exception text is no longer persisted to the withdrawal record's
error_message.
Removed
guest/lookup_fields— declared, saved, and read by nothing; itsModel\Source\LookupFieldssource model is removed with it.
Qoliber_GdprWithdrawalGraphQl
Fixed
confirmWithdrawalreturned the raw exception message to the caller. Exception text routinely quotes the offending value, and this resolver answers unauthenticated clients. It now returns a neutral message.
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprWithdrawalHyva
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
Qoliber_GdprYireoGtm
Fixed
- The dependency on
Qoliber_GdprCorewas never declared. This module'sYireoGtm tag typeimplementsQoliber\GdprCore\Api\TagTypeInterfaceandetc/di.xmlregisters itself on GdprCore's pool, socomposer requirecould resolve it withoutqoliber/gdpr-corepresent and the module would fatal on a class that is simply not there. Installing the full suite hid it, because something else pulled the package in. Now required explicitly at^1.2.
Changed
- Dependency constraints are now pinned. Every
magento/*requirement was an unbound"*", which let Composer resolve a Magento version this code was never tested against, and siblingqoliber/gdpr-*requirements sat at^1.0or"*"even where a 1.2.0 sibling is required —Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added toQoliber_GdprCookiein 1.2.0, so resolving 1.0.0 gave a silently dead button. Magento packages now carry their major.minor floor and suite siblings require^1.2, matching how the suite is built and released. - Version aligned to 1.2.0 for the GDPR Suite 1.2.0 release. No functional changes to this module.
1.1.0 — 2026-07-26
Qoliber_GdprAdmin
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprConsent
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprConsentHyva
Changed
- Restructured the Tailwind sources: extracted
tailwind-source.cssintomodule.css+ component partials and refreshed thetailwind.config.jssafelist so the module's classes survive purge. Frontend Tailwind build only — no PHP or behavioural changes. - Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release (adds EU Right of Withdrawal, Directive (EU) 2023/2673).
Qoliber_GdprCookie
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprCookieHyva
Changed
- Restructured the Tailwind sources: extracted
tailwind-source.cssintomodule.css+ component partials and refreshed thetailwind.config.jssafelist so the module's classes survive purge. Frontend Tailwind build only — no PHP or behavioural changes. - Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release (adds EU Right of Withdrawal, Directive (EU) 2023/2673).
Qoliber_GdprCookieTemplates
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprCore
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprDataSubject
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprDataSubjectHyva
Changed
- Updated the
tailwind.config.jssafelist for the GDPR Suite 1.1.0 build. Frontend Tailwind build only — no PHP or behavioural changes. - Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release (adds EU Right of Withdrawal, Directive (EU) 2023/2673).
Qoliber_GdprDemo
Changed
- Updated the
tailwind.config.jssafelist for the GDPR Suite 1.1.0 build. Frontend Tailwind build only — no PHP or behavioural changes. - Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release (adds EU Right of Withdrawal, Directive (EU) 2023/2673).
Qoliber_GdprFrontend
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprFrontendHyva
Changed
- Updated the
tailwind.config.jssafelist for the GDPR Suite 1.1.0 build. Frontend Tailwind build only — no PHP or behavioural changes. - Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release (adds EU Right of Withdrawal, Directive (EU) 2023/2673).
Qoliber_GdprGtm
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprPolicy
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprPolicyHyva
Changed
- Updated the
tailwind.config.jssafelist for the GDPR Suite 1.1.0 build. Frontend Tailwind build only — no PHP or behavioural changes. - Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release (adds EU Right of Withdrawal, Directive (EU) 2023/2673).
Qoliber_GdprPrivacyCenter
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
Qoliber_GdprPrivacyCenterHyva
Changed
- Restructured the Tailwind sources: extracted
tailwind-source.cssintomodule.css+ component partials and refreshed thetailwind.config.jssafelist so the module's classes survive purge. Frontend Tailwind build only — no PHP or behavioural changes. - Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release (adds EU Right of Withdrawal, Directive (EU) 2023/2673).
Qoliber_GdprWithdrawal
Initial release. Introduces the EU Right of Withdrawal for online sales contracts (Directive (EU) 2023/2673, amending Directive 2011/83/EU), which mandates a clearly visible online withdrawal function from 19 June 2026.
Added
- Withdrawal lifecycle — customer & guest storefront withdrawal forms, an admin grid + detail view, and an observer-driven state machine (new → submitted → confirmed → processing → processed / rejected / expired) with illegal-transition guards.
- Tamper-evident audit trail — every status transition is recorded with a hash-chained history for durable, verifiable proof.
- Eligibility engine — configurable 14-day window with
start_basis(delivery / last shipment / order date), delivery-date resolution, and statutory exclusions (Directive 2011/83/EU Art. 16). - Guarded order actions — cancel / credit-memo via
OrderActionService, gated on a goods-returned check. The standard outbound-shipping refund (Art. 13) is applied on a full withdrawal and zeroed on a genuine partial, keyed on anis_full_requestflag; refund via the same payment means. - Durable-medium acknowledgement — a confirmation email queued on every confirmation, plus request-received and admin-notification templates.
- Art. 11a confirmation block — consumer name, order reference and confirmation email shown before the Confirm button; the authenticated path uses the account email, not the order snapshot.
- Email queue admin surface (grid + status source) and a cron that auto-processes confirmed requests.
- Multi-store aware configuration throughout — all scope reads are keyed to the order's store.
- Quantity-aware over-withdrawal guard and reference generation hardened against concurrent inserts.
Setup\Patch\Data\BackfillIsFullRequest— backfills theis_full_requestflag on pre-existing full rows.- Comprehensive unit + integration test coverage; merchant documentation under
docs/withdrawal/.
Dependencies
qoliber/gdpr-core(^1.0),magento/module-customer,magento/module-sales- PHP 8.1 – 8.5
Qoliber_GdprWithdrawalGraphQl
Initial release. GraphQL API for Qoliber_GdprWithdrawal, shipped as part of
the GDPR Suite 1.1.0 release that introduces the EU Right of Withdrawal
(Directive (EU) 2023/2673).
Added
- Queries/mutations to create a withdrawal, list a customer's withdrawable orders, and read withdrawal status — with authenticated-customer and guest (verify + rate-limited, generic-error) parity to the storefront.
- Store-scoped eligibility resolved from the order's store; the authenticated create path persists and sends the durable acknowledgement to the account email.
Dependencies
qoliber/gdpr-withdrawal(^1.0),magento/module-graph-ql,magento/module-sales- PHP 8.1 – 8.5
Qoliber_GdprWithdrawalHyva
Initial release. Hyvä (strict-CSP) storefront companion for
Qoliber_GdprWithdrawal, shipped as part of the GDPR Suite 1.1.0 release
that introduces the EU Right of Withdrawal (Directive (EU) 2023/2673).
Added
- Hyvä Tailwind/Alpine templates for the customer & guest withdrawal forms, guest lookup, and the order-view withdrawal button — including the Art. 11a confirmation block.
- Strict-CSP-safe Alpine registration via an external
withdrawal-form.js(no inline handlers), guarded against the Alpine init-timing race. - Store-scoped order-view button label/visibility (resolved from the order's store).
- Tailwind config and i18n (en / de / pl).
Dependencies
qoliber/gdpr-withdrawal(^1.0),qoliber/hyva-module-registration- PHP 8.1 – 8.5
Qoliber_GdprYireoGtm
Changed
- Version aligned to 1.1.0 for the GDPR Suite 1.1.0 release, which introduces the EU Right of Withdrawal (Directive (EU) 2023/2673 — mandatory online withdrawal function from 19 June 2026). No functional changes to this module.
1.0.5 — 2026-06-11
Qoliber_GdprConsent
Added
- Configurable "Link Text" on consent definitions (REAC-7). New nullable
link_textcolumn + admin form field: the{link}placeholder in the checkbox label now renders the merchant's text instead of the hardcoded "Privacy Policy" (which remains the fallback when empty). Honoured by the text and CMS-page renderers and the checkout consent config.
Qoliber_GdprCookie
Fixed
- Hyvä cookie banner showed categories with no cookies (REAC-10).
ViewModel\CookieConsent::getCategoriesWithCookies()now drops categories that have no cookie details — there is nothing for the visitor to allow or deny. Luma's Knockout banner already filtered these client-side; the shared server-side filter makes both themes consistent.
Qoliber_GdprPolicy
Changed
- The privacy-policy consent renderers (Luma + Hyvä) honour the consent definition's new "Link Text" (
link_text, see Qoliber_GdprConsent 1.0.5) for the{link}placeholder, falling back to the policy title / "Privacy Policy".
1.0.4 — 2026-06-11
Qoliber_GdprConsentHyva
Fixed
- Strict-CSP Hyvä Checkout froze on "Proceed to payment" (critical; missed in the 1.0.3 release sync). The checkout consent template appended an inline
<script>after the Magewire component's root element; on every Magewire morphdom re-render the script is re-executed viaeval(), which a strict checkout CSP (nounsafe-eval) blocks — the step transition rejected and checkout never advanced (plus a "Multiple root elements" Livewire warning). ThegdprCheckoutConsentsAlpine component is now registered in the static, CSP-hashedbefore.body.endblock and the inline script is gone; the Magewire template ends at its single root element.
Qoliber_GdprDataSubject
Fixed
- Luma delete-account confirmation was still case-sensitive. The 1.0.3 case-insensitive "type DELETE" fix shipped for Hyvä but this module's Luma template was missed in the release sync — lowercase
deletestill flagged the field red and failedvalid()(re-triggering the all-fields-red wash). The Luma validator, submit guard and live-feedback handler now compare case-insensitively, matching Hyvä.
1.0.3 — 2026-06-11
Qoliber_GdprAdmin (2026-06-09)
Fixed (marketplace-hardening review, 2026-06-10)
- Packaging: composer now declares the module's real dependencies (
gdpr-consent,gdpr-cookie,gdpr-data-subject,gdpr-policy) and the module sequence includes GdprCookie/GdprPolicy — the dashboards, readiness auditor and audit export query those modules' tables/classes, so a partial install produced fatal admin pages.
Fixed
- "Data Subject Requests" overview grid actions 404'd. The GDPR Dashboard "View all requests" grid exposed Process/Reject/Delete mass actions, a "Create New Request" button and inline edit — none of which have controllers in this module (it has only
Requests/Index), so every one 404'd. Removed them; the grid is now a read-only overview. Manage requests via GDPR → GDPR Requests.
Qoliber_GdprConsent
Fixed
- Admin hardening: consent Delete/Save and all mass actions (Delete/Enable/Disable) are now HttpPost-only; Edit/Index/New declare HttpGet. The grid's row Delete action POSTs with a form key, and the edit form's Delete button was replaced with a
DeleteButtonblock that POSTs viadeleteConfirm(..., {"data": {}})(the previous plain XML button emitted a GET, which the POST-only controller would reject). - Packaging: composer now declares the module's real dependencies (
module-checkout,module-contact,module-newsletter,module-quote,module-store— plugin/extension-attribute targets that previously brokesetup:di:compileon partial installs); removed the no-opMagento_Cookiesequence entry.
Qoliber_GdprConsentHyva (2026-06-10)
Fixed
setup:di:compilefailed on Hyvä-theme-without-Hyvä-Checkout installs. The checkout consent Magewire component extends Magewire/Hyvä-Checkout classes that composer never required. Declaredhyva-themes/magento2-hyva-checkoutas a dependency; splitting the component into a dedicatedGdprConsentHyvaCheckoutmodule is scheduled for 1.1 (ROADMAP F18.3).- CSP cleanup: removed leftover
hyva-csp-helpergenerator comments (no functional change).
Qoliber_GdprCookie (2026-06-09)
Fixed (review round 2, 2026-06-11)
- Cookie-detail edit form's Delete button now POSTs with a form key (
deleteConfirm(..., {"data": {}})) — the Delete controller is HttpPost-only since the previous round.DetectedCookie\MassDeleteis now HttpPost-only too. - Package cycle with GdprFrontend broken:
ViewModel\CookieConsentnow consumes the TagType pool from GdprCore (see GdprCore 1.0.3); theqoliber/gdpr-frontendcomposer requirement is gone.
Fixed (marketplace-hardening review, 2026-06-10)
- Admin hardening: cookie-category delete now requires POST (was GET), cookie-detail delete/save/edit/new/index and category save now declare granular
ADMIN_RESOURCEACL (previously any admin role could execute them; cookie-detail delete carried a deadisAllowed()override the framework never calls). Grid Delete actions now POST with a form key. - Packaging: declared the real
qoliber/gdpr-frontendcomposer dependency (ViewModel\CookieConsentimports its TagType pool); a standalone install no longer breaks compilation. The proper decoupling is scheduled for 1.1 (ROADMAP F18.2).
Fixed
- Luma cookie-consent theme/colours had no effect on the storefront. The banner's
data-themeattribute was applied by a fragile one-shot JS that raced KnockoutJS rendering, so the[data-theme="…"]CSS variables/rules never matched the rendered element (Dark/Light/Custom all looked identical). The four KO banner templates (popup, bottom, sidebar-left, sidebar-right) now binddata-themetosettings.themedirectly, so the theme and Custom colour palette apply reliably. (Hyvä already server-rendereddata-themeand was unaffected.)
Qoliber_GdprCookieHyva (2026-06-10)
Fixed
- CSP cleanup: removed leftover
hyva-csp-helpergenerator comments from the Hyvä templates (per AGENTS.md; no functional change).
Qoliber_GdprCookieTemplates (2026-06-10)
Fixed
- Admin hardening: the template-picker JSON controller now declares a granular
ADMIN_RESOURCE(Qoliber_GdprAdmin::gdpr_cookies_manage); its previousisAllowed()override used a method name the framework never calls, so any admin role could query it.
Qoliber_GdprCore
Added
Api\TagTypeInterfaceandModel\TagType\TagTypePool(moved here from GdprFrontend, which keeps deprecated BC shims). Hosting the tag-type contract in the shared core module breaks the GdprCookie ⇄ GdprFrontend package dependency cycle; GdprFrontend/GdprGtm/GdprYireoGtm now wire their tag types into the GdprCore pool.
Qoliber_GdprDataSubject (2026-06-09)
Fixed (review round 2, 2026-06-11)
- Guest anonymization state machine was inconsistent. (a) Requesting anonymization now immediately moves the original fetch request to
anonymization_requested, so the data link stops serving personal data while erasure is pending. (b) The guest view located the anonymization request by the original fetch token — which can never match the anonymize row's own token — so the pending/complete banner never rendered; it is now matched viaoriginal_request_id. (c) The terminalanonymization_completed/anonymization_failedstatuses (new interface constants, also used by the cron) are accepted by the guest view/controller whitelists as status-only states, so the guest's link shows the erasure outcome instead of "invalid link" — without ever serving data again. - PII log hygiene (round 2): removed remaining raw subject emails from DSAR logs (anonymization request/cron, guest submit, restriction/objection crons, customer lookup) — request IDs are the correlator.
Fixed (marketplace-hardening review, 2026-06-10)
- Guest data-ready links were dead on arrival.
Cron\ProcessDataFetchRequestsmarked a requestcompletedright after emailing the download link, but the download/view gates only acceptnotified/accessed— so every emailed link was rejected. The cron now setsnotified(matchingProcessDataAccessNotifications); completion follows access/expiry. - Admin hardening: request Block/Unblock/Mark-Reviewed are now POST-only (grid actions and the detail-page buttons submit with a form key; previously plain GET links).
- PII log hygiene: DSAR/export paths no longer write raw subject emails or full stack traces to application logs — request IDs or truncated email hashes are used as correlators, exceptions log
class @ file:line.
Fixed
- Admin request "Process" / "Delete" actions 404'd. The request grid's row actions (perform/resend/export/archive) and mass "Delete", and the detail page's "Process/Approve" buttons, all pointed at controllers that never existed (
gdpr/request/{perform,resend,export,archive,massDelete}). Removed them. Request fulfilment is performed automatically by the per-type cron once a request is pending — honouring the right to erasure/access without undue delay — so admin approval is not a gate; the admin's controls are View, Notes, Block/Unblock (legal hold) and Review. - A denied request left the customer account locked out permanently. The account is locked when a deletion/anonymization request is submitted, but nothing released the lock on denial. Added
Service\CustomerLookupService::unlockCustomerAccount()and call it fromDenyDelete/DenyAnonymize, so a denied request re-enables the customer's login.
Qoliber_GdprDataSubjectHyva (2026-06-09)
Fixed (review round 2, 2026-06-11)
- Guest invalid-link branch rendered malformed Alpine markup — it opened
<div x-data="gdprGuestViewData">(a component only registered in the data branch) and returned without closing the wrapper. The error box is now static, complete markup. The anonymization branches also key on the new terminal statuses, with a status belt that never renders data while erasure is pending/failed.
Fixed (marketplace-hardening review, 2026-06-10)
- CSP cleanup: removed leftover
hyva-csp-helpergenerator comments from the Hyvä templates (per AGENTS.md; no functional change).
Fixed
- Delete-account form was confusing and could not be submitted with valid data. The "type DELETE" confirmation is now case-insensitive (lowercase
deleteis accepted; the uppercased display no longer mismatches a case-sensitive check), live validation uses@inputinstead of@change, and the acknowledgement checkbox now reads.checkedinstead of the string"on". A fully and validly filled form now enables the submit button instead of staying disabled/red.
Qoliber_GdprFrontend (2026-06-10)
Changed (review round 2, 2026-06-11)
Api\TagTypeInterfaceandModel\TagType\TagTypePoolmoved to GdprCore (deprecated BC shims remain); di.xml tag-type wiring now targets the GdprCore pool. This breaks the GdprCookie ⇄ GdprFrontend package cycle: the dependency is now one-way (GdprFrontend → GdprCookie).- Removed the unused
Qoliber_GdprConsent/Qoliber_GdprDataSubjectsoft sequence entries (no code, layout or config references); sequence now declares the real dependencies (GdprCookie, Magento_GoogleGtag).
Fixed
- Packaging: declared the module's real composer dependencies —
qoliber/gdpr-cookie(the storefrontViewModel\CookieConsent, instantiated on every page, extends GdprCookie's view model: a standalone install fataled the whole storefront) andmagento/module-google-gtag(di.xml plugsMagento\GoogleGtag\Block\Ga). The structural decoupling is scheduled for 1.1 (ROADMAP F18.2).
Qoliber_GdprGtm
Changed
- Tag type now implements the GdprCore
TagTypeInterfaceand registers in the GdprCoreTagTypePool(moved from GdprFrontend). - Packaging: declared the real
qoliber/gdpr-cookiedependency (the frontend layout instantiates its CookieConsent view model); removed the no-opMagento_GoogleGtagsequence entry (no code, layout or block interaction).
Qoliber_GdprPolicy (2026-06-09)
Fixed (marketplace-hardening review, 2026-06-10)
- REST policy-consent endpoints could never authorize.
etc/webapi.xmlreferenced the ACL resourcepolicy_consent_view, butacl.xmldefinespolicy_consents_view— every token was rejected. Fixed the reference. - Undeclared hard dependency on Qoliber_GdprCookie. Data patches inject GdprCookie repositories and
di.xmlplugs its CookieConsent view model, so a partial install fataledsetup:upgrade/di:compile. Declared the dependency (composer + module sequence) and pointed both cookie-seeding patches'getDependencies()atInstallDefaultCookiesso patch ordering can no longer silently skip seeding.
Fixed
- "Privacy Policy" consent link 404'd (e.g. on the registration form).
Block\Consent\Renderer\PrivacyPolicy::getPolicyUrl()fell back to agdpr/policy/viewroute that does not exist. Repointed it to the realgdpr/policy/renderendpoint so the consent link / policy modal never resolves to a 404.
Qoliber_GdprPolicyHyva (2026-06-10)
Fixed
- CSP cleanup (per AGENTS.md): the privacy-policy modal now binds
x-datadirectly to the canonicalQoliberGdprPrivacyPolicycomponent; removed the redundant generatedgdprPrivacyPolicyalias wrapper script and leftoverhyva-csp-helpergenerator comments.
Qoliber_GdprYireoGtm
Changed
- Tag type now implements the GdprCore
TagTypeInterfaceand registers in the GdprCoreTagTypePool(moved from GdprFrontend).
1.0.2 — 2026-06-07
Qoliber_GdprAdmin
Fixed
- "Data Subject Requests" details page led to a 404. The requests grid action column (
Ui\Component\Listing\Column\RequestActions) built the View link asqoliber_gdpr/request/view, butQoliber_GdprAdminhas no such controller — the data-subject request detail view is owned byQoliber_GdprDataSubjecton thegdpradmin route. Reachable from the GDPR Dashboard's "View All Requests" button, clicking a row's View therefore hit a non-existent route and 404'd. Repointed the link to the real controllergdpr/request/view(sameidparameter). Covered by a unit test.
Qoliber_GdprConsent (2026-06-06)
Fixed
- Guest checkout failed with a TypeError (critical).
PaymentInformationManagementPluginwas bound to both the logged-inMagento\Checkout\Model\PaymentInformationManagementand the guestMagento\Checkout\Model\GuestPaymentInformationManagement, but the guest interface inserts$emailas the second argument — so on every guest order the$emailstring was passed into thePaymentInterface $paymentMethodparameter and threw aTypeError, blocking the order. AdditionallysaveConsentData(int $cartId, …)type-hinted the cart id asint, which a masked-string guest cart id also broke. Split the logic into a dedicatedPlugin\Checkout\Model\GuestPaymentInformationManagementPluginwith the correct guest signature, keptPaymentInformationManagementPluginfor the logged-in flow, moved the shared consent extraction intoService\Checkout\PaymentConsentSaver, and dropped the unused/incorrectly-typed$cartId. Covered by unit tests. - Privacy-policy consent link stale on installs upgraded from 1.0.0-RC. In 1.0.0 the
privacy_policyconsent's routing changed (display_typecms_page_link→privacy_policy_link,link_url/privacy-policy-cookie-restriction-mode→gdpr/policy/render), butInstallDefaultConsentsis run-once and insert-only, so RC-upgraded stores kept the stale values and the consent's policy link routed to the CMS renderer (which has no page id) and failed. Added an idempotentSetup/Patch/Data/ReconcilePrivacyPolicyConsentLinkthat corrects the row only when it is still at the exact RC defaults (fresh installs and merchant-customised rows are left untouched).
Removed
- Dead, redundant quote-based checkout consent path.
Observer\CheckoutSubmitAllAfter(read consent off the quote) was never registered in anyevents.xml, so theShippingInformationManagementPluginthat wrote consent to the quote on every checkout shipping step fed nothing — a wasted quote save and a latent double-recording landmine (had the observer ever been registered,ConsentTrackerhas no per-order dedup, so every Luma order would have logged its consents twice). Consent is captured via the payment/Magewire → checkout-session path and persisted by the unifiedObserver\PersistCheckoutConsents(see Changed). Removed the orphaned observer,Plugin\Checkout\Model\ShippingInformationManagementPluginand its two di bindings, and theshipping-save-processorJS mixin. TheCartInterface/ShippingInformationInterfaceconsent extension attributes are now unused and will be removed in a future minor.
Changed
- Unified checkout consent capture across Luma and Hyvä. The two storefronts stashed checkout consent under different checkout-session keys (
qoliber_gdpr_consentvsqoliber_gdpr_consents), each consumed by its own observer on overlapping events — a divergence that risked silent misses and, if ever naively aligned, double-recording (ConsentTrackerhas no per-order dedup). Introduced a single shared key,Model\Checkout\ConsentSession::KEY, used by both writers, and one base-module observerObserver\PersistCheckoutConsentson the universalcheckout_submit_all_afterevent that records consent for every storefront (accepting both the serialized-string and raw-array payload shapes). Removed the Luma-onlyObserver\CheckoutOnepageSuccess; the redundantQoliber_GdprConsentHyvaobserver is removed too (see its changelog).
Qoliber_GdprConsentHyva
Changed
- Unified checkout consent capture with the Luma stack. The Hyvä consent Magewire component now stashes consent under the shared
Qoliber\GdprConsent\Model\Checkout\ConsentSession::KEYinstead of its own private session key, and recording is handled by the single base-module observerQoliber\GdprConsent\Observer\PersistCheckoutConsents(oncheckout_submit_all_after). Removed this module's now-redundantObserver\CheckoutSubmitAllAfterObserverand itsetc/events.xml. No behavioural change for Hyvä checkout — consent is still recorded once per order; the two storefronts simply no longer use divergent session keys/observers. SeeQoliber_GdprConsent1.0.2.
Qoliber_GdprCookie (2026-06-06)
Fixed
- "Cookie Type" select rendered blank for some cookies. The Cookie Detail form and listing offered only
HTTP/local_storage/session_storage, but the seed data storespersistent(×13) andsession(×1) too. With no matching option,Magento_Ui/js/form/element/selectresolved the value toundefined, so admins saw a blank Type and re-saving could silently clear it. Added a canonicalModel\Source\CookieTypesource (covering all five values) used by the form; the listing column carries the matching inline option set (grid select columns need an inline array, not a source object). Covered by a unit test.
Added
Setup/Patch/Data/ReconcileFunctionalityCookies— idempotent patch that movesproduct_data_storageandsection_data_idsto the Functionality category on stores upgraded from 1.0.0-RC. The 1.0.0InstallDefaultCookiesreclassified these two cookies (Essential → Functionality) but, being run-once and insert-only, never moved them on existing installs, leaving them wrongly flagged as required/essential. No-op on fresh installs where they are already correct.
Qoliber_GdprPolicy (2026-06-06)
Fixed
- "Cookie Consent Mode Text" was ignored when Privacy Policy Type = CMS Page.
Plugin\ViewModel\CookieConsentPluginrendered the merchant-configured consent text only for the version-based policy type; in CMS-page mode it emitted a hardcoded, untranslatable English sentence ("For more information about how we handle your data…") and ignored the configured text. The configured text is now authoritative for both policy types, with the hardcoded link kept only as a fallback when no consent text is set. Covered by an e2e test. - Hardened the privacy-policy render path against opaque "Error loading privacy policy".
Controller\Policy\Renderand both renderers (VersionBasedRenderer,CmsPageRenderer) now catch\Throwable(not just\Exception) and log the exception class + file:line, so a PHPErrorreturns clean JSON and a diagnosable log line instead of a 500 HTML page that the storefront surfaced as a generic failure. The effective-date placeholder replacement now degrades to today's date on a malformed stored date instead of failing the whole policy load. Covered by an e2e test.
1.0.1 — 2026-06-02
Qoliber_GdprAdmin
Fixed
- GDPR Consent Logs admin grid failed to load — it showed "0 records found" with an "Something went wrong with processing the default view and we have restored the default view" error and a Something went wrong modal.
etc/di.xmlregistered theqoliber_gdpr_consent_log_listing_data_sourcecollection a second time (virtualTypeQoliberGdprConsentSearchResult→ tableqoliber_gdpr_consent_log), overriding the correct binding declared inQoliber_GdprConsent(→qoliber_gdpr_consent_customer_log). BecauseQoliber_GdprAdminis<sequence>d afterQoliber_GdprConsent, its di.xml merged last and the wrong mapping won. The grid's listing XML targets theqoliber_gdpr_consent_customer_logschema (PKlog_id, columnsconsent_code,form_location,is_accepted,accepted_at), none of which exist inqoliber_gdpr_consent_log— so the first render threwSQLSTATE Unknown column 'log_id'. Removed the stray binding and the now-orphanedQoliberGdprConsentGridDataProvider/QoliberGdprConsentSearchResultvirtualTypes so the correctQoliber_GdprConsentdata source takes effect. Verified by resolving the live grid collection: it now queriesqoliber_gdpr_consent_customer_logand returns rows without error.
Qoliber_GdprCookie
Fixed
- Cookie Detail form "Cookie Category" dropdown could hide categories —
Ui/Component/Form/Element/CategoryOptionsonly listed categories withis_enabled = 1, while the cookie listing grid shows all categories. A cookie saved against a category that was later disabled lost its value on edit, because a<select>cannot render an option that is not in its option list (this matches the "category doesn't appear on edit" report). Removed theis_enabledfilter so the form lists every category, consistent with the listing grid. Note: an entirely empty dropdown on a fresh install means no cookie categories have been seeded — runbin/magento setup:upgradeto apply theInstallDefaultCookies/InstallGoogleConsentCategorydata patches.
1.0.0 — 2026-05-22
Qoliber_GdprCore
Added
- Initial release of the shared GDPR domain kernel module.
Qoliber\GdprCore\Model\Enum\LawfulBasis— GDPR Art. 6 lawful-basis backed string enum (Consent, Contract, Legal Obligation, Legitimate Interests).Qoliber\GdprCore\Model\Source\LawfulBasisSource—OptionSourceInterfaceadapter for admin forms/grids.Qoliber\GdprCore\Service\AgeGateService— GDPR Art. 8 age-of-digital-consent helper. Computes age from DOB, exposescanGiveConsent()/canGiveConsentStrict()/requiresParentalConsent(). Threshold configurable (13–18, default 16) underqoliber_gdpr/age_gate/threshold; gate toggle atqoliber_gdpr/age_gate/enabled(disabled by default).- Domain enums (PHP 8.1 modernisation, WS5):
RequestStatus(backed string, 13 cases) withisTerminal()/isActive()helpers.RequestType(backed string, 10 cases) witharticleReference()mapping each right to its GDPR Art. 15–21 citation.PolicyStatus(backed int, 4 cases — draft/active/published/archived) withisPublic()/isEditable()helpers.ConsentAction(backed string, 2 cases — granted/revoked) withisPositive().- Legacy class-constants in consuming modules remain for back-compat; new code should prefer the enums.
Qoliber_GdprDemo
Fixed (release prep — QLB_GDPR_V2.1, 2026-04-27 → 2026-04-28)
ViewModel/DemoLinks::getCustomerRequestUrl— "Request My Data" demo link now points at the canonical Privacy Center surface (customer/account/privacy) instead of the retiredgdprds/request/indexroute. The old route was deleted in this release (seeQoliber_GdprDataSubjectCHANGELOG); the demo bar would have surfaced a 404 otherwise.
Added
- Production-mode gate (2026-04-21): the demo bar is automatically hidden
when Magento runs in production mode (
bin/magento deploy:mode:show==production). Merchants can override per-store viaqoliber_gdpr_demo/settings/force_enabled(default: 0) for demo/staging instances that deliberately run in production mode.DemoLinks::isEnabled()is the single source of truth, exercised by both Luma and Hyvä templates. - Initial release — storefront demo helper bar
- Top-of-page ribbon with a
DEMObadge and quick links to every public GDPR flow:- Guest Data Request (
gdprds/guest/datarequest) - Privacy Center (
customer/account/privacy, login required) - Request My Data (
gdprds/request/index, login required) - Consent History (
gdpr-consent/customer/index, login required) - Privacy Policy (
gdpr/policy/render) - Cookie Preferences — re-opens the cookie consent banner by clearing stored consent + reloading
- Guest Data Request (
- Two styled variants:
- Luma: purple/indigo gradient + Less stylesheet (
view/frontend/web/css/source/_module.less) - Hyvä: Tailwind v4 utilities with Alpine.js cookie-reopen handler and Hyvä CSP registration
- Luma: purple/indigo gradient + Less stylesheet (
ViewModel\DemoLinkssupplies all URLs + aisCustomerLoggedIn()helper so links that require authentication are flagged with a "(login required)" hint
Non-goals
- Not intended for production merchants. The bar is meant to help demo visitors (and partners evaluating the suite) discover every GDPR flow without hunting for URLs.
- Module sequences after all core Qoliber GDPR modules so its layout handles apply last.
Requirements
- PHP 8.1, 8.2, 8.3, or 8.4
qoliber/gdpr-data-subject: ^1.0qoliber/gdpr-privacy-center: ^1.0qoliber/gdpr-policy: ^1.0qoliber/gdpr-cookie: ^1.0