107 min readOct 16, 2026by jakub

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_FR and es_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-metapackage requires ^1.4 for 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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 denied and 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_FR and es_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 before setup:di:compile.
  • Omitting the form marker no longer switches enforcement off. The check returned "nothing missing" when the request carried no qoliber_gdpr_consent_form field, 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, savePaymentInformation and placeOrder now 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 a link_url when no CMS page is chosen. ReconcileSeededConsentLinks fills 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Known limitations

  • A third-party Luma payment method that places the order without going through the checkout's place-order mixin 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_store and qoliber_gdpr_consent_website; a new column is_restricted on qoliber_gdpr_consent_definition; link_text, link_url and cms_page_id on qoliber_gdpr_consent_store_label (which shipped in 1.0.0 but was never read or written); and the foreign key from qoliber_gdpr_consent_customer_log.store_id to store is dropped — the column and its values stay. The BackfillConsentRestriction data patch marks every consent restricted to specific store views today. Run bin/magento setup:upgrade, and in production mode setup:di:compile and setup: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 send true, 1 or "1". A custom form whose checkbox carries no value attribute posts the browser default on, which is no longer read as acceptance — give it value="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 ReconcileSeededConsentLinks data 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-hyva 1.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) and ViewModel\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-consent enforce their required consents on the server.
  • Switching the checkout consent step off now switches it off on Hyvä. qoliber_gdpr/consent/checkout_enabled was 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 in qoliber/gdpr-consent 1.4.0 now answers the question, and the wrapper, the component, the layout (through ifconfig) 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 <script in 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, and verify-release-findings fails 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 calls unsetData(), 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Added

  • Translations. The module shipped none. Every phrase it shows is now translated in en_US, de_DE, pl_PL, fr_FR and es_ES. French and Spanish are machine-quality and awaiting native review.

Upgrade notes

  • Requires qoliber/gdpr-consent ^1.4, the release that adds ConsentDefinition\Collection::addStoreFilter() and the server-side consent check. The constraint is raised in composer.json, so Composer refuses an update that would pair this module with an older gdpr-consent (where the Hyvä checkout would fail with an undefined-method error); update both together, or run composer update qoliber/gdpr-metapackage -W.
  • Constructors changed: Magewire\Checkout\GdprConsents and ViewModel\CheckoutConsentsAvailability gain StoreManagerInterface.

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_FR and es_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 analytics and marketing and ignored the Google Services category outright, so accepting everything on Hyvä left analytics_storage denied while the same click on Luma granted it, and functionality_storage rode 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 only security_storage; Functionality carries functionality_storage and 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_case and camelCase, and templates picked whichever they were written against. googleConsentConfig() is now the single source, in camelCase and carrying signals; the snake_case keys 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 and is_active defaults 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, and qoliber/gdpr-core 1.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. ReconcileAnalyticsCategoryWording gives 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Upgrade notes

  • No schema change. Run bin/magento setup:upgrade, and in production mode setup:di:compile and setup:static-content:deploy (the cookie manager's JavaScript changed).
  • ReconcileAnalyticsCategoryWording runs 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-consent 1.4.0, whose endpoint accepts them; an older gdpr-consent rejects them as before. qoliber/gdpr-metapackage 1.4.0 installs the two together.
  • ViewModel\CookieConsent takes four new constructor dependencies, all optional and resolved on their own, so a subclass built against the 1.3 constructor (such as qoliber/gdpr-frontend's) keeps working. Constructors that changed outright: Controller\Adminhtml\CookieCategory\Save and Controller\Adminhtml\CookieDetail\Save gain StoreTexts; Model\CookieCategory\DataProvider and Model\CookieDetail\DataProvider gain StoreTexts and StoreTextsMeta.

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.stop uses a named handler instead of two empty x-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_storage along with the always-on Essential category and started it granted, 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, from qoliber/gdpr-cookie 1.4.0's Model\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.phtml compatibility template now uses the canonical server-provided Consent Mode signal map and qoliber_gdpr_google_consent payload. A merchant layout override can no longer restore from the obsolete category payload, grant functionality_storage unconditionally, 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Added

  • Translations. The module shipped none. Every phrase it shows is now translated in en_US, de_DE, pl_PL, fr_FR and es_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 run composer update qoliber/gdpr-metapackage -W.
  • Refusals reach the consent log with qoliber/gdpr-consent 1.4.0.

Qoliber_GdprCookieTemplates

Changed

  • Version aligned with the suite-wide 1.4.0 release; qoliber/gdpr-metapackage requires ^1.4 for 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Qoliber_GdprCore

Added

  • Translations. The module shipped none. Every phrase it shows is now translated in en_US, de_DE, pl_PL, fr_FR and es_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 require qoliber/gdpr-consent can use them.

Changed

  • Version aligned with the suite-wide 1.4.0 release; qoliber/gdpr-metapackage requires ^1.4 for 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.md re-measured for 1.4.0.

Fixed

  • Test\Integration\CronScheduleExecutionTest failed 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\SeedPatchesWriteRealColumnsTest reads 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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 with setup: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.json allows 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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\ShopperLabels and Model\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 mode setup: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-hyva 1.4.0 and qoliber/gdpr-privacy-center 1.4.0 require this release.
  • Constructors changed: Cron\ProcessDataFetchRequests (+Emulation), Cron\ProcessDeletionRequests (+Registry), Model\DataConverter\CsvConverter and TxtConverter (+DataLabels), Service\EmailService (+RequestTypeSource) and ViewModel\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 from gdpr-data-subject's new ViewModel\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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Added

  • Translations. The module shipped none. Every phrase it shows is now translated in en_US, de_DE, pl_PL, fr_FR and es_ES. French and Spanish are machine-quality and awaiting native review.

Upgrade notes

  • Requires qoliber/gdpr-data-subject ^1.4 (raised from ^1.2) for ViewModel\ShopperLabels; with an older gdpr-data-subject both pages would fail to render. Update both together, or run composer 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_FR and es_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-metapackage requires ^1.4 for 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Added

  • Translations. The module shipped en_US only, so the embed placeholder stayed English on every store view. Every phrase is now translated in en_US, de_DE, pl_PL, fr_FR and es_ES. French and Spanish are machine-quality and awaiting native review.

Upgrade notes

  • No schema change. Model\EmbedConsentRecorder's constructor gains EmbedProviderPool.

Qoliber_GdprFrontend

Added

  • Translations. The module shipped none. Every phrase it shows is now translated in en_US, de_DE, pl_PL, fr_FR and es_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-cookie 1.4.0 (Model\ShippedTexts). Wording a merchant typed is shown as typed. Test\Unit\BannerDefaultsAreTranslatableTest fails if a default is added that has no translation.

Changed

  • Version aligned with the suite-wide 1.4.0 release; qoliber/gdpr-metapackage requires ^1.4 for every module, so "GDPR Suite 1.4.0" never resolves a mix of old and new modules.
  • Its CookieConsent view 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.phtml hardcoded functionality_storage: 'granted' in its gtag('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 in qoliber/gdpr-cookie 1.4.0 (Model\GoogleConsent\SignalMap), which grants only security_storage up front.
  • A released tag now contains what the tag says. composer.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Qoliber_GdprFrontendHyva

Changed

  • Version aligned with the suite-wide 1.4.0 release; qoliber/gdpr-metapackage requires ^1.4 for 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.phtml hardcoded functionality_storage: 'granted' in its gtag('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 in qoliber/gdpr-cookie 1.4.0 (Model\GoogleConsent\SignalMap), which grants only security_storage up front. This is the same correction made on the Luma side, so both themes send Google the same state. Paired with qoliber/gdpr-frontend 1.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 run composer update qoliber/gdpr-metapackage -W.

  • A released tag now contains what the tag says. composer.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Qoliber_GdprGtm

Added

  • Translations. The module shipped none. Every phrase it shows is now translated in en_US, de_DE, pl_PL, fr_FR and es_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-metapackage requires ^1.4 for 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_default and consent_update events onto the dataLayer and called gtag('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 through gtag('consent', 'default', …) before the container loads and gtag('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-cookie 1.4.0 (Model\GoogleConsent\SignalMap), so functionality_storage starts denied and is granted only by the Functionality category, and only security_storage is 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 through SecureHtmlRenderer otherwise; 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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_consent context.
  • 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_type and country_code were declared only in db_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_consent and identifier had 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 posts 25/10/2026 for a French admin, 01.10.26 for 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. AddPrivacyPolicyCookie set a purpose and a sort order that no column exists for and spelled the active flag is_enabled; AddPolicyVersionCookie passed 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 and is_active defaults 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Added

  • Translations. Every phrase is translated in en_US, de_DE, pl_PL, fr_FR and es_ES. French and Spanish are machine-quality and awaiting native review.

Known limitations

  • requires_consent is 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_date and identifier on qoliber_gdpr_privacy_policy; on qoliber_gdpr_policy_consent, policy_id becomes nullable and cms_page_id, policy_type, customer_email, consent_method and country_code are added. The BackfillPrivacyPolicyIdentifier data patch gives existing policies an identifier from their title and keeps their last-updated date. Run bin/magento setup:upgrade, and in production mode setup:di:compile and setup: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/log must send a context; cookie_consent is refused.
  • qoliber/gdpr-policy-hyva 1.4.0 requires this release.
  • Constructors changed: ViewModel\PrivacyPolicy and Model\Renderer\CmsPageRenderer use GetPageByIdentifierInterface instead of the page factory; Model\Renderer\VersionBasedRenderer no longer takes a search criteria builder; Plugin\ViewModel\CookieConsentPlugin gains the PrivacyPolicy view model; Controller\Adminhtml\PrivacyPolicy\Save gains 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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 run composer 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-subject 1.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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Added

  • Translations. Every phrase is translated in en_US, de_DE, pl_PL, fr_FR and es_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-metapackage requires ^1.4 for 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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-metapackage requires ^1.4 for 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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-metapackage requires ^1.4 for 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-metapackage requires ^1.4 for 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

Qoliber_GdprYireoGtm

Added

  • Translations. The module shipped none. Every phrase it shows is now translated in en_US, de_DE, pl_PL, fr_FR and es_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-metapackage requires ^1.4 for 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_update and yireo_consent_update events onto the dataLayer and called gtag('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 through gtag('consent', 'default', …) before Yireo's container loads and gtag('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-cookie 1.4.0 (Model\GoogleConsent\SignalMap), so functionality_storage starts denied and is granted only by the Functionality category, and only security_storage is 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 through SecureHtmlRenderer otherwise; 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.json pinned its own source.reference to main, 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 whatever main held at the time — an install pinned to ^1.3 could 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 a version either — the same mutable-file-over-immutable-tag mistake one step further on, and what made composer validate --strict fail.

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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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.security advisory 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-metapackage requires ^1.3 across 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.security advisory 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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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.md re-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. Adds tests/tools/gate.sh, which collapses the unit, phpcs and PHPStan stages into one command with one exit status, and records why config-policy and config-toggles were split out of config-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-metapackage requires ^1.3 across the board. The substance of 1.3.0 is in qoliber/gdpr-data-subject and qoliber/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 read confirmed and both proceed, so the subject received two exports and two emails, and the second run overwrote the first's files. Claiming is now a conditional UPDATE ... 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 processing forever. Only confirmed rows 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 completed and 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_exports directory 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_blocked filter 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 to notified. ProcessEmailQueue did not touch the request row at all, so a completed DSAR stayed at notification_pending — and the access and download gates accept notified/accessed only. 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 records notification_failed against 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_id into 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 = processing alone, 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 to notified on 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_pending and notification_failed statuses, 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_code and claim_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 failed with 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 ExportStorageInterface implementation writes encrypted exports under var/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 deployment var/gdpr_exports therefore has to be shared storage (NFS, EFS or equivalent) — or ExportStorageInterface bound 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:upgrade after 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 by ProcessDataFetchRequests in 1.1.0 and left behind: no crontab.xml entry referenced it, and its selection — request_type = fetch_data with status = pending — is a pair nothing produces, since requests are created pending_confirmation and verification promotes them straight to confirmed. 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 request notified even 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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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 read pending, both write processing, and both then send. Claiming is now a conditional UPDATE honoured only when it affects exactly one row.
  • A row stranded by a crash was never retried. Only pending was ever selected, so a worker that died mid-send left the row at processing permanently 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_at now 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 UPDATE fenced 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_message persisted 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 stable last_error_code.

Added

  • processing_started_at, next_attempt_at and last_error_code on 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 and qoliber/gdpr-data-subject onto ^1.3 of core and make them lockstep consumers for roughly forty lines of UPDATE ... 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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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-metapackage requires ^1.3 across 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) and qoliber/gdpr-withdrawal (the email outbox concurrency contract).

Added

  • support.security in composer.json, pointing at &lt;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_id and customer_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.
  • ConsentLog resource model named a column that does not exist. It declared log_id as the identity of qoliber_gdpr_consent_log, whose primary key is consent_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 IS consent_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 no customer_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 Redirect docblock — so a module may legally override the controller and return something else, and an AJAX subscribe form returning Result\Json is the ordinary case on a Hyvä storefront. NewActionPlugin::afterExecute() type-hinted Redirect, turning a supported customisation into a fatal TypeError on every subscription attempt. Reported from a live store.
  • The same defect sat one step behind in Manage\SavePlugin, which hinted ResponseInterface. Result\Json implements ResultInterface — 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 return mixed and constrain nothing.

Added

  • Security advisory feed declared. composer.json now carries a support.security pointer 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 same Json\Interceptor named 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 $magewire to consult; it now uses a CheckoutConsentsAvailability view 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/clearConsentError tolerate a missing checkbox; validateRequiredConsents/attachConsentsToForm no-op without a bound form (they stay reachable from outside the component via form._x_dataStack, where init()'s own guard never applied); validateConsents tolerates $el being absent; and the .closest('.field') chains no longer assume a match.
  • Most importantly, the window.initNewsletterForm and window.initContactForm wrappers no longer wrap a factory that does not return a submitForm. 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.json now carries a support.security pointer 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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/enabled had no config.xml default, and Qoliber_GdprCookieHyva gates all six of its storefront blocks on it via ifconfig. 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 entire requests group (auto_approve_access, request_expiry_days, deletion_grace_period), the entire automation group (auto_delete_inactive, inactive_period_days, auto_anonymize_orders, order_anonymize_days) and the entire notifications group (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 two config.xml defaults sat under a data_retention node that system.xml calls automation, 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 ObjectManager service 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 a Proxy in etc/frontend/di.xml on purpose: the block is attached in default.xml and 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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_days and 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 with Cron\PurgeConsentLogs behind 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_enabled now has an admin field. Model\Checkout\ConsentConfigProvider has always read this path, but no system.xml declared it, so its value came from config.xml and no merchant could change it. Declared with a config_path override 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 CustomerRegisterSuccess observer 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.subscribe in default.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 on customer_logged_in and customer_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 on newsletter_manage_index, which core marks cacheable="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 and GdprPolicy each carried a private copy.
  • A customer whose email is null no longer aborts account creation. CustomerInterface::getEmail() is nullable while ConsentLinkService is strict-typed, so the raw value raised a TypeError — an \Error, which the observer's catch (\Exception) cannot catch.

Removed

  • The duplicate qoliber_gdpr_consent/consent/checkout_enabled default, 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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-cookie on its own returned HTTP 500 on every storefront page. getReopenerType(), getDisplayStyle() and getTheme() are declared : string under strict_types and returned scopeConfig->getValue() with no fallback, while those defaults ship only in Qoliber_GdprFrontend — which this module does not require. The footer link block has no ifconfig, so it renders on every page: getValue() returned null, null from a : string is a TypeError, and Layout::renderNonCachedElement() catches \Exception rather 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 without Qoliber_GdprFrontend got a banner with no title, no message and no policy text, silently. The copy now falls back to the translated defaults getTexts() 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 a data-consent-category, and let the consent component swap it for a live script once that category is allowed. That swap lived only in js/view/cookie-consent.js, which carries a requirejs alias but is initialised by no template — the Luma banner mounts js/view/privacy-preferences.js, whose own toggleScripts() 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 published qoliberGdpr*Enabled jQuery 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/enabled and its detector on detection/client_side_enabled, but both defaults shipped only in Qoliber_GdprFrontend, which this module does not require and deliberately does not sequence (the reverse direction would be circular). composer require qoliber/gdpr-cookie on its own therefore produced a store with no cookie banner at all, silently. Both defaults, plus qoliber_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 (see Qoliber_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 importCookie event 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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_consent in localStorage plus the qoliberGoogleConsentUpdated event — was written only by a RequireJS module the Luma banner pulls in, and Hyvä layouts remove that banner. Nothing emitted it, so Qoliber_GdprFrontendHyva, Qoliber_GdprGtm and Qoliber_GdprYireoGtm all waited forever. It failed silently, because the Hyvä template defines a gtag() dataLayer stub: consent updates were pushed to an array no library ever read. The call was also guarded on gtag already existing, which was circular — the loaders wait for this signal before loading gtag. Fixing that exposed a second divergence: Hyvä hardcoded only analytics and marketing and ignored the google_services category, so the same "Allow All" granted analytics_storage on 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.qoliberGdprCompliance is 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, treating navigator.globalPrivacyControl as 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_categories scope 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 from cookie_consent_config, parks it in temp storage and returns as though it had written. gdpr_consent was never registered — gdpr_policy_version was, so the mechanism was understood — so the cookie never reached document.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_consent is now registered in essential and necessary — 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 a data-consent-category stayed 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 via registerInlineScript() — 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 dedicated compliance-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 (see Qoliber_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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 own generated/, why the Playwright project order is load-bearing, and which skips and flakes are expected rather than symptoms.
  • Api\IpMinimizerInterface and Model\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 yields null rather than an empty string, because "no address recorded" and "an empty address was recorded" are different facts.

Fixed

  • getRemoteAddress() returns false despite its @return string docblock, which raised a TypeError — an \Error that escapes catch (\Exception) — and broke registration. Cast at every call site, with a regression guard.
  • A SearchResultsInterface return-type incompatibility that made setup:di:compile fatal. 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_type came 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 Knockout html binding 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. Submit now allowlists the type against every DataRequestInterface::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_email alone, leaving ip_address, user_agent, response_data, admin_user, blocked_reason — and request_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 called setEmail(), 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_grid had neither address column written, and the invoice and credit-memo grids were missing shipping_address — only the shipment grid was complete. Street, city and postcode therefore stayed visible and searchable in Sales grids indefinitely. The order's customer_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() and NewsletterDataFetcher::isEnabled() returned an unconditional true, so export_settings/include_order_data and include_newsletter_data had 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. CustomerAddressDataAnonymizer gated on anonymization_settings/anonymize_customer_addresses, a path with no admin field and no config.xml default, so isSetFlag() 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_anonymization and enable_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 carry ifconfig.
  • The admin was never notified of rectification requests. gdpr_admin_notification was set as a template identifier and registered in no email_templates.xml, so getTransport() threw and the caller swallowed it. Registered — in the frontend area, because an adminhtml registration cannot resolve design/email/header_template and 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. RectificationPost now 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_STORE and no store id and hardcoded trans_email/ident_support, ignoring notification/sender_identity entirely. 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 EmailService log calls beside a scrubbed exception; and ProcessEmailQueue persisted $e->getMessage() into email_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 stable QUEUE_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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 $escape to fputcsv(). 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) and retention/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; DataSubjectConfigInterface is 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() on dataset.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 a TypeError before 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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.com host — deliberately, since a google.com iframe is a transfer to Google whatever the path — and host matching strips a leading www., so it also claimed https://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, so google.com paths 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/embed reads 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, and data-embed-tag records which element was removed so consent rebuilds a script rather than pointing an iframe at a .js file (it defaults to iframe, 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 own src attribute, 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.js now 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 new consent_scope column (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/enabled had 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 provisions de and fr views, since Art. 7(2) expects a data subject to be told what they are consenting to in a language they understand. A new Api\PlaceholderCopyInterface, injected into EmbedRewriter, now supplies the four strings; the Magento-backed implementation runs them through __(), with source keys shipped in i18n/en_US.csv. EmbedRewriter itself 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_GdprCore was never declared. This module's Api\TagTypeInterface and Model\TagType\TagTypePool are BC shims that extends the GdprCore classes of the same name, and etc/di.xml configures Qoliber\GdprCore\Model\TagType\TagTypePool directly, so composer require could resolve it without qoliber/gdpr-core present 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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_GdprCore was never declared. This module's GoogleTagManager tag type implements Qoliber\GdprCore\Api\TagTypeInterface and etc/di.xml registers itself on GdprCore's pool, so composer require could resolve it without qoliber/gdpr-core present 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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/check accepted a caller-supplied customerId and 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 :policyType route in webapi.xml that made one endpoint unreachable.
  • Policy IP minimisation now delegates to the shared GdprCore implementation instead of a private copy.

Added

  • Model\PolicyConsentSearchResults with explicit typed overrides, fixing a di:compile incompatibility.

Qoliber_GdprPolicyHyva

Fixed

  • The dependency on Qoliber_GdprConsent was never declared. Plugin\Model\Consent\Renderer\PrivacyPolicyRendererPlugin type-hints Qoliber\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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 Throwable in the Monolog context. The message was scrubbed, but NormalizerFormatter re-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; its Model\Source\LookupFields source model is removed with it.

Qoliber_GdprWithdrawalGraphQl

Fixed

  • confirmWithdrawal returned 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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_GdprCore was never declared. This module's YireoGtm tag type implements Qoliber\GdprCore\Api\TagTypeInterface and etc/di.xml registers itself on GdprCore's pool, so composer require could resolve it without qoliber/gdpr-core present 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 sibling qoliber/gdpr-* requirements sat at ^1.0 or "*" even where a 1.2.0 sibling is required — Qoliber_GdprEmbed's "Always allow" button, for instance, needs the listener added to Qoliber_GdprCookie in 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.css into module.css + component partials and refreshed the tailwind.config.js safelist 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.css into module.css + component partials and refreshed the tailwind.config.js safelist 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.js safelist 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.js safelist 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.js safelist 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.js safelist 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.css into module.css + component partials and refreshed the tailwind.config.js safelist 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 an is_full_request flag; 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 the is_full_request flag 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_text column + 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 via eval(), which a strict checkout CSP (no unsafe-eval) blocks — the step transition rejected and checkout never advanced (plus a "Multiple root elements" Livewire warning). The gdprCheckoutConsents Alpine component is now registered in the static, CSP-hashed before.body.end block 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 delete still flagged the field red and failed valid() (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 DeleteButton block that POSTs via deleteConfirm(..., {"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 broke setup:di:compile on partial installs); removed the no-op Magento_Cookie sequence entry.

Qoliber_GdprConsentHyva (2026-06-10)

Fixed

  • setup:di:compile failed on Hyvä-theme-without-Hyvä-Checkout installs. The checkout consent Magewire component extends Magewire/Hyvä-Checkout classes that composer never required. Declared hyva-themes/magento2-hyva-checkout as a dependency; splitting the component into a dedicated GdprConsentHyvaCheckout module is scheduled for 1.1 (ROADMAP F18.3).
  • CSP cleanup: removed leftover hyva-csp-helper generator 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\MassDelete is now HttpPost-only too.
  • Package cycle with GdprFrontend broken: ViewModel\CookieConsent now consumes the TagType pool from GdprCore (see GdprCore 1.0.3); the qoliber/gdpr-frontend composer 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_RESOURCE ACL (previously any admin role could execute them; cookie-detail delete carried a dead isAllowed() override the framework never calls). Grid Delete actions now POST with a form key.
  • Packaging: declared the real qoliber/gdpr-frontend composer dependency (ViewModel\CookieConsent imports 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-theme attribute 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 bind data-theme to settings.theme directly, so the theme and Custom colour palette apply reliably. (Hyvä already server-rendered data-theme and was unaffected.)

Qoliber_GdprCookieHyva (2026-06-10)

Fixed

  • CSP cleanup: removed leftover hyva-csp-helper generator 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 previous isAllowed() override used a method name the framework never calls, so any admin role could query it.

Qoliber_GdprCore

Added

  • Api\TagTypeInterface and Model\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 via original_request_id. (c) The terminal anonymization_completed/anonymization_failed statuses (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\ProcessDataFetchRequests marked a request completed right after emailing the download link, but the download/view gates only accept notified/accessed — so every emailed link was rejected. The cron now sets notified (matching ProcessDataAccessNotifications); 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 from DenyDelete / 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-helper generator 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 delete is accepted; the uppercased display no longer mismatches a case-sensitive check), live validation uses @input instead of @change, and the acknowledgement checkbox now reads .checked instead 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\TagTypeInterface and Model\TagType\TagTypePool moved 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_GdprDataSubject soft 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 storefront ViewModel\CookieConsent, instantiated on every page, extends GdprCookie's view model: a standalone install fataled the whole storefront) and magento/module-google-gtag (di.xml plugs Magento\GoogleGtag\Block\Ga). The structural decoupling is scheduled for 1.1 (ROADMAP F18.2).

Qoliber_GdprGtm

Changed

  • Tag type now implements the GdprCore TagTypeInterface and registers in the GdprCore TagTypePool (moved from GdprFrontend).
  • Packaging: declared the real qoliber/gdpr-cookie dependency (the frontend layout instantiates its CookieConsent view model); removed the no-op Magento_GoogleGtag sequence 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.xml referenced the ACL resource policy_consent_view, but acl.xml defines policy_consents_view — every token was rejected. Fixed the reference.
  • Undeclared hard dependency on Qoliber_GdprCookie. Data patches inject GdprCookie repositories and di.xml plugs its CookieConsent view model, so a partial install fataled setup:upgrade/di:compile. Declared the dependency (composer + module sequence) and pointed both cookie-seeding patches' getDependencies() at InstallDefaultCookies so 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 a gdpr/policy/view route that does not exist. Repointed it to the real gdpr/policy/render endpoint 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-data directly to the canonical QoliberGdprPrivacyPolicy component; removed the redundant generated gdprPrivacyPolicy alias wrapper script and leftover hyva-csp-helper generator comments.

Qoliber_GdprYireoGtm

Changed

  • Tag type now implements the GdprCore TagTypeInterface and registers in the GdprCore TagTypePool (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 as qoliber_gdpr/request/view, but Qoliber_GdprAdmin has no such controller — the data-subject request detail view is owned by Qoliber_GdprDataSubject on the gdpr admin 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 controller gdpr/request/view (same id parameter). Covered by a unit test.

Qoliber_GdprConsent (2026-06-06)

Fixed

  • Guest checkout failed with a TypeError (critical). PaymentInformationManagementPlugin was bound to both the logged-in Magento\Checkout\Model\PaymentInformationManagement and the guest Magento\Checkout\Model\GuestPaymentInformationManagement, but the guest interface inserts $email as the second argument — so on every guest order the $email string was passed into the PaymentInterface $paymentMethod parameter and threw a TypeError, blocking the order. Additionally saveConsentData(int $cartId, …) type-hinted the cart id as int, which a masked-string guest cart id also broke. Split the logic into a dedicated Plugin\Checkout\Model\GuestPaymentInformationManagementPlugin with the correct guest signature, kept PaymentInformationManagementPlugin for the logged-in flow, moved the shared consent extraction into Service\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_policy consent's routing changed (display_type cms_page_link → privacy_policy_link, link_url /privacy-policy-cookie-restriction-mode → gdpr/policy/render), but InstallDefaultConsents is 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 idempotent Setup/Patch/Data/ReconcilePrivacyPolicyConsentLink that 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 any events.xml, so the ShippingInformationManagementPlugin that 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, ConsentTracker has 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 unified Observer\PersistCheckoutConsents (see Changed). Removed the orphaned observer, Plugin\Checkout\Model\ShippingInformationManagementPlugin and its two di bindings, and the shipping-save-processor JS mixin. The CartInterface/ShippingInformationInterface consent 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_consent vs qoliber_gdpr_consents), each consumed by its own observer on overlapping events — a divergence that risked silent misses and, if ever naively aligned, double-recording (ConsentTracker has no per-order dedup). Introduced a single shared key, Model\Checkout\ConsentSession::KEY, used by both writers, and one base-module observer Observer\PersistCheckoutConsents on the universal checkout_submit_all_after event that records consent for every storefront (accepting both the serialized-string and raw-array payload shapes). Removed the Luma-only Observer\CheckoutOnepageSuccess; the redundant Qoliber_GdprConsentHyva observer 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::KEY instead of its own private session key, and recording is handled by the single base-module observer Qoliber\GdprConsent\Observer\PersistCheckoutConsents (on checkout_submit_all_after). Removed this module's now-redundant Observer\CheckoutSubmitAllAfterObserver and its etc/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. See Qoliber_GdprConsent 1.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 stores persistent (×13) and session (×1) too. With no matching option, Magento_Ui/js/form/element/select resolved the value to undefined, so admins saw a blank Type and re-saving could silently clear it. Added a canonical Model\Source\CookieType source (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 moves product_data_storage and section_data_ids to the Functionality category on stores upgraded from 1.0.0-RC. The 1.0.0 InstallDefaultCookies reclassified 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\CookieConsentPlugin rendered 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\Render and both renderers (VersionBasedRenderer, CmsPageRenderer) now catch \Throwable (not just \Exception) and log the exception class + file:line, so a PHP Error returns 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.xml registered the qoliber_gdpr_consent_log_listing_data_source collection a second time (virtualType QoliberGdprConsentSearchResult → table qoliber_gdpr_consent_log), overriding the correct binding declared in Qoliber_GdprConsent (→ qoliber_gdpr_consent_customer_log). Because Qoliber_GdprAdmin is <sequence>d after Qoliber_GdprConsent, its di.xml merged last and the wrong mapping won. The grid's listing XML targets the qoliber_gdpr_consent_customer_log schema (PK log_id, columns consent_code, form_location, is_accepted, accepted_at), none of which exist in qoliber_gdpr_consent_log — so the first render threw SQLSTATE Unknown column 'log_id'. Removed the stray binding and the now-orphaned QoliberGdprConsentGridDataProvider / QoliberGdprConsentSearchResult virtualTypes so the correct Qoliber_GdprConsent data source takes effect. Verified by resolving the live grid collection: it now queries qoliber_gdpr_consent_customer_log and returns rows without error.

Qoliber_GdprCookie

Fixed

  • Cookie Detail form "Cookie Category" dropdown could hide categories — Ui/Component/Form/Element/CategoryOptions only listed categories with is_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 the is_enabled filter 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 — run bin/magento setup:upgrade to apply the InstallDefaultCookies / InstallGoogleConsentCategory data 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 — OptionSourceInterface adapter for admin forms/grids.
  • Qoliber\GdprCore\Service\AgeGateService — GDPR Art. 8 age-of-digital-consent helper. Computes age from DOB, exposes canGiveConsent() / canGiveConsentStrict() / requiresParentalConsent(). Threshold configurable (13–18, default 16) under qoliber_gdpr/age_gate/threshold; gate toggle at qoliber_gdpr/age_gate/enabled (disabled by default).
  • Domain enums (PHP 8.1 modernisation, WS5):
    • RequestStatus (backed string, 13 cases) with isTerminal() / isActive() helpers.
    • RequestType (backed string, 10 cases) with articleReference() mapping each right to its GDPR Art. 15–21 citation.
    • PolicyStatus (backed int, 4 cases — draft/active/published/archived) with isPublic() / isEditable() helpers.
    • ConsentAction (backed string, 2 cases — granted/revoked) with isPositive().
    • 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 retired gdprds/request/index route. The old route was deleted in this release (see Qoliber_GdprDataSubject CHANGELOG); 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 via qoliber_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 DEMO badge 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
  • 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
  • ViewModel\DemoLinks supplies all URLs + a isCustomerLoggedIn() 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.0
  • qoliber/gdpr-privacy-center: ^1.0
  • qoliber/gdpr-policy: ^1.0
  • qoliber/gdpr-cookie: ^1.0
Changelog — GDPR Compliance Suite — Compliance & Legal — Extensions | qoliber Docs