Skip to content

<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /changelog/Changelog.4.121.md for this page in Markdown format</div>

Home | Changelog

Version 4 ​

version 4.121 ​

  • [4.121.0] feat(admin/payments): narrow the external-frontend payway panels to a curated, verified pool (Advisable-com/ecommercen#679)

    • #672's two external-frontend panels (Settings → Payment settings → "External frontend payment methods" / "External frontend gift card payment methods") offered the merchant every payway allPayWays() knows — 20 keys — because that panel's own acceptance criterion required matching the legacy storefront panel key-for-key. Only a subset has actually been driven end-to-end through an external frontend (nuxt / velora / mobile) with real credentials; #673 faithfully advertises whatever is selected over GET /rest/checkout/payment-methods and accepts it at place-order, so an unverified selection is not harmless.
    • New helper getExternalPayWays() (ecommercen/helpers/eshop_helper.php), alongside getCardPayWays() / getGiftCardPayWays(). It is a capability list: delivery, bank_transfer, paid_at_store, vivawallet — payways verified end-to-end on an external frontend, not merely payways with credentials configured or an adapter registered. piraeus is deliberately absent (see Notes).
    • The external payments panel's available side is now array_intersect_key(allPayWays(), array_flip(getExternalPayWays())), mirroring the shape getGiftCardPayWays() already uses. It also no longer unions in getVivaEnabledPaymentMethods() — the viva sub-method keys (vivawallet_credit_card, vivawallet_ideal, …) never resolved to an adapter on an external channel in the first place, since VivaWalletAdapter::getKey() and PaymentInitializer::getRegisteredPaymentMethods() only ever map the parent vivawallet key.
    • The external gift-card panel's available side is driven by a second, dedicated helper, getExternalGiftCardPayWays() (also in eshop_helper.php), returning ['vivawallet']: array_intersect_key(allPayWays(), array_flip(getExternalGiftCardPayWays())). It is not derived from getExternalPayWays() — buying a gift card from an external frontend is a separate verification from external checkout, with different POS terminals and different code paths (PIRAEUSBANK_GIFTCARDS_EXTERNAL vs. PIRAEUSBANK_EXTERNAL; viva's own separate giftCardSourceCode vs. its checkout flow). Intersecting the two lists would assert that verifying checkout also verifies gift cards, which does not hold. This also mirrors legacy, where getGiftCardPayWays() has always been independent of the payments roster rather than derived from it. The codebase now hand-maintains five payway lists: getExternalPayWays(), getExternalGiftCardPayWays(), getCardPayWays(), getGiftCardPayWays(), and allPayWays() itself.
    • Adv_settings::payment_settings() now filters two of the page's five payway selections against their respective pools on save: PAYWAY_EXTERNAL against getExternalPayWays(), and PAYWAY_GIFT_CARDS_EXTERNAL against getExternalGiftCardPayWays() (same array_values(array_intersect($this->input->post(...) ?? [], …)) shape for each). Three remain unfiltered: PAYWAY, PAYWAY_ADMIN, PAYWAY_GIFT_CARDS. See Notes for why the gift-card leg is filtered even though nothing gates order placement on it.
    • A drift guard (tests/Unit/Helpers/PayWayDebrisCoverageTest.php) asserts every pool entry is a real allPayWays() key. It cannot and does not assert completeness — an absence from the pool is a deliberate human decision (verification status), not something the codebase can derive; see the design rationale in docs/decisions/679-external-payway-pool.md.
  • [4.121.0] feat(payments/piraeus): add per-channel Piraeus POS terminal credentials for the external/mobile frontends (Advisable-com/ecommercen#674)

    • Why. Piraeus issues a separate POS terminal per channel — one set of credentials cannot serve all of them. Two credential groups already existed, fully independent of each other: PIRAEUSBANK (legacy storefront checkout) and PIRAEUSBANK_GIFTCARDS (legacy gift cards). This adds the two external/mobile channels.
    • New credential groups. PIRAEUSBANK_EXTERNAL and PIRAEUSBANK_GIFTCARDS_EXTERNAL, each read by its own helper (getPiraeusExternalBankSettings(), getPiraeusGiftCardExternalBankSettings()) over the identical 13-key field set the existing readers use — including INSTALLMENTS, so installments resolve per channel. Nothing is inherited from the group above it; every value comes from its own group, matching the existing invariant for the checkout/gift-card pair.
    • Admin UI. Two new credential blocks in Settings → Payment settings → Piraeus Bank: "External / mobile credentials" (rendered unconditionally, mirroring the checkout block) and "External / mobile Gift Card credentials" (rendered only when gift cards are enabled, mirroring its sibling block).
    • The real defect this fixes. PaymentInitializerFactory::registerPiraeus() previously read the legacy checkout group (PIRAEUSBANK), so the modern/REST checkout path (Checkout\PlaceOrderService, Rest\Checkout\Controllers\Checkout) was silently transacting on the legacy web POS terminal instead of its own. It now reads PIRAEUSBANK_EXTERNAL. No channel detection was needed or added — the factory's only two consumers are both REST, and the rendered storefront calls the legacy reader directly, so the modern factory is the external channel.
    • Webhook. POST /rest/webhooks/piraeus keeps its route, its handler and every response shape — this is a behaviour change, not a new endpoint. It now validates the callback HMAC against each configured Piraeus POS terminal in turn (PIRAEUSBANK, then PIRAEUSBANK_EXTERNAL), accepting on the first match, because the callback payload carries no POS identifier and a payment taken on the external POS would otherwise fail validation against the checkout POS's credentials.
    • Commits: 4cc665b3b (registry groups + helpers + admin save/render legs), 7cc1ffc66 (src/** — factory + webhook), 33a33bbf0 (admin view credential blocks).
  • [4.121.0] feat(rest/checkout): honour METHODS.PAYWAY_EXTERNAL, fail closed when unset (Advisable-com/ecommercen#673)

    • GET /rest/checkout/payment-methods now reports merchant INTENT, not configured credentials. It intersects the registered payment adapters (PaymentInitializerFactory) with the METHODS.PAYWAY_EXTERNAL registry key #672 added, and returns them in the merchant's saved order — the admin panel's order — rather than the factory's registration order. The offline adapters (delivery, bank_transfer, paid_at_store) are filtered like any other payway, not special-cased.
    • Fail-closed when unset. An unset or empty METHODS.PAYWAY_EXTERNAL yields [] — no payment methods — and refuses every placement. It does not inherit METHODS.PAYWAY (the rendered storefront's list) and does not mean "no filtering". Ratified on epic #671.
    • POST /rest/checkout/place-order now refuses a payway the merchant did not select, with a 422 carrying a distinguishable error.code (payway_not_available, new PaywayNotAvailableException). The gate is the first statement of PlaceOrderService::placeOrder(), before any side effect — the guest-customer insert, the order insert, the points debit, the cart clear, the gateway charge — so a refused order writes nothing at all.
    • This is a behaviour change on a REST contract, but it affects no client today: nothing currently consumes /rest/checkout/payment-methods, so there is no live caller for either half to break. Say both halves plainly — this is not "additive": a previously-populated response can now come back empty, and a previously-accepted place-order request can now be refused.
    • A merchant must configure Settings → Payments (the #672 panel) before any external frontend can take payment at all. That is the designed forcing function, not a defect — for a client being onboarded onto nuxt/velora/mobile, an empty paymentMethods[] means "nobody has filled in the panel yet", not a bug.
    • semver:minor (the issue label) is correct because no client is live yet; it would not be once one is.
    • Commit: 2ca252a7b.
  • [4.121.0] feat(admin/payments): add external-frontend payway selection panels to Settings → Payment settings (Advisable-com/ecommercen#672)

    • Two new dual-multiselect panels let a merchant curate which payways external frontends (nuxt, velora, the mobile app) may offer — one panel for payments ("External frontend payment methods"), one for gift cards ("External frontend gift card payment methods", shown only when gift cards are enabled). They mirror the existing storefront/admin/gift-card panels on the same page.
    • The controller (ecommercen/settings/controllers/Adv_settings.php) saves the selections to two new registry keys: METHODS.PAYWAY_EXTERNAL (unconditionally, mirroring METHODS.PAYWAY) and METHODS.PAYWAY_GIFT_CARDS_EXTERNAL (inside the existing GIFT_CARDS.ENABLED guard, so a forged POST can't write it while gift cards are off). New label keys were added to all eight language bundles.
    • This slice only makes the selection configurable — nothing reads these two keys at runtime yet. That intersection against the registered payment adapters (and failing closed when unset) is issue #673.
    • Commits: 5a374c4c8 (controller + language bundles), e3de81718 (the two admin views).
  • [4.121.0] fix(legacy): scope hasProducts() to the category being deleted (Advisable-com/ecommercen#669)

    • Adv_product_category_model::hasProducts(int $id) (ecommercen/eshop/models/Adv_product_category_model.php:206) never used its $id — with no where(), the emitted SQL was SELECT product_id FROM shop_product_category_lp LIMIT 1, which answers "does this table contain any row at all", true on every live shop. canDeleteRecord() is hasChildren($id) ? false : !hasProducts($id), so it always evaluated to false: no product category could be deleted on any install. The failure was silent — the admin delete action guards with canDeleteRecord() and then redirects unconditionally, with no else and no flash, so it read as an unresponsive button rather than a refusal.
    • Introduced by 3b4b83a054 (2026-02-09) — the commit that added the products check is what broke deletion, so deletion has been impossible for roughly six and a half months.
    • The fix (d2d0e6476e) adds the missing predicate, ->where('category_id', $id), and nothing else. The rule — a category may be deleted only when it has no child categories and no products — is unchanged; this restores that rule rather than altering it. canDeleteRecord() and hasChildren() (already correct, and the reference shape) are untouched.
  • [4.121.0] fix(video): stop fatalling on an unconfigured or non-BUNNY video-stream provider; degrade to "no provider" instead (Advisable-com/ecommercen#663)

    • The root cause, one match shape, four copies. VIDEOSHOWCASE.PROVIDER resolution used a match whose intended fallback arm was written as the quoted string 'default' — matching the literal text "default", not the default keyword. Any other value, including the registry's unset state (a shop that never configured the showcase), matched no arm and threw \UnhandledMatchError, which made the is_null($provider) guard immediately below each site unreachable. Fixed to the default keyword at: ecommercen/eshop/controllers/Adv_home.php:410, ecommercen/job/libraries/AdvCheckVideoStatusStream.php:35, ecommercen/job/libraries/AdvDeleteVideoToStream.php:35, ecommercen/job/libraries/AdvUploadVideoToStream.php:35.
    • A second, independent defect in the same area. ecommercen/eshop/controllers/Adv_reels.php passed null into VideoManager::__construct(VideoStream $videoStream, ...), a non-nullable parameter — a TypeError. That happens in the constructor, before any "is the showcase enabled" check, so /reels and /api/reels returned a hard 500 even when the video showcase was switched off — the operator-visible headline of this fix. $videoManager is now nullable and buildPaginatedVideos() returns an empty result when no provider is configured, instead of constructing a VideoManager around a null stream.
    • Behaviour unchanged for the configured case. PROVIDER='BUNNY' behaviour is unchanged on all five surfaces (the homepage stream lookup, the three video-stream jobs, and /reels//api/reels) — this is a degradation fix for the unconfigured/other-provider path, not a behaviour change to the working path.
  • [4.121.0] test(domains/support): match visibility-scope targets across the PSR-4 root, so the positive invariant sees a fork's namespace-local relation copies (Advisable-com/ecommercen#661)

    • What changed. #641's positive invariant walks custom/Domains on purpose — a fork's own RepositoryConfigurator, aliased over its upstream counterpart in custom/Domains/container.php, is the class the DI container actually serves, and a stale copy of it is the whole hazard. For one relation shape that walk was inert. auditRequiredVisibilityScopes() selected relations with a raw isset($registry[$target]) that did not strip the PSR-4 root, while matchingExemption() deliberately did. This delivery routes the target lookup through the same withoutPsr4Root() helper, via a new registeredTargetFor().
    • The shape that escaped is a NAMESPACE-LOCAL TARGET, not a self-referential relation. A target written as a bare Repository::class resolves out of the declaring file's own namespace, so copying a whole Repository/ directory and rewriting only the namespace root shifts it to Custom\… without a character of the relation changing. Those copies matched no registry key, hit the continue, and were never checked at all — not offenders, not exempt, not even counted — with the per-entry blind floor staying quiet because upstream's own relations still matched. src/Domains declares 48 such targets — 9 bare Repository::class (the recursive parent/children pairs on Blog\Category, Blog\Comment, Cms\Page and Product\Category, plus Product\Category.relativeCategories) and 39 bare MuiRepository::class, every translations relation onto a same-namespace MuiRepository. Only the first group can reach a registered target today; the 39 are harmless purely because no MuiRepository is in the registry, and if one is ever added they all shift together. (Counted here by resolving each new Relation( target against its file's use imports and keeping the unqualified ones. #661's triage counted 38 and was correct at its base: #648 added Cms\Reel's translations between that commit and this one, and it is the only relation-bearing configurator added in the range. Re-derive rather than trusting either number.)
    • Live as of #658, which is why this landed next. Cms\Page's two relations are declared as a bare self-referential Repository::class, so registering that target was exactly what made the gap reachable. #658 shipped with a KNOWN FORK-COVERAGE LIMITATION note on its registry entry saying upstream coverage was complete and fork coverage partial; that note is now false and has been restated in place — fork coverage is complete for both copy shapes, with the one bound below.
    • The bound that survives, recorded in registeredTargetFor()'s docblock in the file's existing plain-limitation style. Matching is by trailing namespace, so a Custom\… class is reached only when it sits at the exact mirror path of a registered target. Measured, not assumed: a fork's genuinely new Custom\Domains\Marketplace\Widget\Repository\Repository matches no entry and is still skipped, and …\Repository\MuiRepository never collides with …\Repository\Repository because only the PSR-4 root is stripped, never a trailing segment. That bound is deliberate rather than a hole — by this repo's own alias convention a class at the mirror path is that repository's override, while a fork's genuinely new domain lives at custom/Domains/&lt;New>/. So the guard asks a fork about its overrides of registered targets and about nothing else; there was no widening to choose.
    • Demonstrated failing-then-passing, with a control. A new test_the_invariant_reports_a_fork_copy_whose_relation_target_is_namespace_local() carries a Custom\ relation key and a Custom\ target — the pair that was skipped. Both existing fork fixtures pair a Custom\ key with an Advisable\ target, which raw isset() already caught, so copying their shape would have produced a test that passes without testing anything. The fixture's second row is the control: the same file, same missing scope, same relation shape, target written as the upstream FQCN. With the pre-fix lookup restored, exactly one of the two is reported and the test fails; with the fix, both are reported. Two further rows pin the bound from the other side — a fork's namespace-local MuiRepository and a fork's genuinely new module, both unscoped, both correctly ignored.
    • $checked keying is structural, not a decision. It is seeded array_fill_keys(array_keys($registry), 0), so only registry keys can ever exist in it; a fork's copy increments the entry it overrides. The new test pins that a Custom\… target never introduces a key of its own, so a future change that keyed on the raw target cannot quietly split one target's coverage in two.
    • Nothing else moved. visibilityScopeExemptions(), matchingExemption() and withoutPsr4Root() are byte-identical, and so is the allow-list guard test_only_the_reviewed_relations_declare_a_visibility_scope() and its 27 pinned keys — verified by md5 of each region at the base commit and at HEAD, not by reading the diff. No registry entry was added or removed: this changes coverage mechanics, while #656, #657, #659 and #660 change coverage membership. test_each_registered_repository_covers_the_relations_its_review_found() still pins Product 17, Article 4, Page 2 — it walks allRelations(), upstream only, and never sees a Custom\ target in any tree.
    • No production code, no migration, no REST contract change, no new config key. Test-only.
  • [4.121.0] test(domains/support): register the blog-tag repository in the visibility-scope positive invariant, completing the registry at seven entries (Advisable-com/ecommercen#660)

    • What changed. #641 shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-null visibilityScope, unless explicitly exempted — and #655, #658, #656, #657 and #659 added its second through sixth entries. The one relation reaching Cms\Blog\Tag\Repository\Repository was still outside it: scoped by #640, with nothing asserting it stayed scoped. This delivery adds Cms\Blog\Tag\Repository\Repository => BlogTagVisibilityScope as the seventh entry. It is the last. Seven *VisibilityScope classes exist in src/ and all seven are now registered, so the registry docblock's running list of not-yet-registered targets is retired — replaced by a statement that the map is complete, with the "enabling one is a REVIEW, not a formality" rationale deliberately kept and re-pointed at any future eighth scope class. That sentence is what stops a future entry being added as a one-liner; retiring the list it qualified is not a reason to retire it.
    • What a stale fork copy was exposing. Cms\Blog\Article.tags embeds on the guest-readable GET /rest/cms/blog/article, so a fork whose custom/Domains copy of Blog\Article's configurator predates #640 returns inactive blog tags to unauthenticated callers through ?with=tags, at every ?with= depth that reaches a tag. The DI alias in custom/Domains/container.php makes that copy the class actually served, so the container boots clean, php cli.php job/check-container is green, and the allow-list guard sees nothing — a Custom\... key contributes nothing to the array it pins. Only the positive invariant notices, and only once this target is registered in it. The rule itself is straight legacy parity, not a tightening: Adv_blog_tags_model::getTagsFront() has always selected is_active = 1 (ecommercen/blog/models/Adv_blog_tags_model.php:24-27), and = 1 rather than != 0 withholds NULL-flagged rows exactly as legacy did. That is the asymmetry with the blog-category entry, whose rule is a tightening, and it is why #640 built two scope classes rather than one parameterised by table.
    • Zero exemptions and zero allow-list edits were needed. The single relation was already scoped by #640, and its key has been pinned in test_only_the_reviewed_relations_declare_a_visibility_scope() since then. visibilityScopeExemptions(), matchingExemption(), withoutPsr4Root(), registeredTargetFor(), instantiate() and the allow-list guard with its pinned keys are identical to the base, established at the git-blob level — no diff hunk touches any of them, which is the simpler and directly reproducible proof (a working-tree file comparison would need line endings normalised first, since the checkout is CRLF and blobs are LF) — this delivery changes coverage membership, not mechanics.
    • The audit was done by resolved class-string, not by grep. Measured over the 200 relations across 115 configurators the guard walks: exactly 1 relation resolves onto the target — Cms\Blog\Article.tags, MANY_TO_MANY, with a non-null visibilityScope — declared in another module through an aliased import (use ...Cms\Blog\Tag\Repository\Repository as TagRepository, Cms\Blog\Article\Repository\RepositoryConfigurator:11), so an escaped-FQCN sweep misses it outright. Zero unscoped relations onto the target, therefore zero exemptions.
    • ⚠️ This target is NOT namespace-local — it is one of three registered entries with no own hop. Measured by resolved class-string: Cms\Blog\Tag (1 inbound, 0 own) sits alongside Product\Product (17 inbound, 0 own) and Cms\Blog\Article (4 inbound, 0 own), whose configurators likewise take their only namespace-local target to be a MuiRepository. The four entries that do own a bare self-referential Repository::class hop are Cms\Page (2 of 2), Product\Category (3 of 4), Cms\Blog\Comment (2 of 3) and Cms\Blog\Category (2 of 3). This target carries none in either direction: its inbound relation is aliased from another module, and its own configurator declares only translations onto Cms\Blog\Tag\Repository\MuiRepository, which is not a registry key today. So the fork shape #661 exists for — a copied Repository/ directory shifting a bare Repository::class into Custom\... — cannot arise from this target's own configurator, and the entry deliberately carries no fork-coverage note. A fork copying Blog\Article's configurator still lands on this entry, because that copy keeps naming the upstream FQCN through its import.
    • ⚠️ The one non-additive part: a control row in another test had to be rewritten, and it fired first. test_the_invariant_reports_an_article_relation_whose_visibility_scope_went_missing() (from #655) carried a deliberately unscoped Article.tags fixture row onto Cms\Blog\Tag\Repository\Repository as a "must NOT be reported" control, whose comment ended: "if this row ever starts being reported, someone enabled a target without doing that review." Registering the target turned that row into a genuine offender, the offender count went 1 → 2 and the test went red — the tripwire firing correctly, and this delivery is the review it was waiting for. It could not be replaced in kind, because after this delivery no unregistered target exists anywhere in the tree. It was therefore retargeted onto Cms\Blog\Article\Repository\MuiRepository, kept unscoped — preserving the exact property, skipped because of its target rather than by a null check, using a class that is structurally never a registry target. It was not scoped (that changes the control's kind to skipped-because-a-scope-is-present and silently retires the only target-based skip in that test) and not deleted (it is what makes assertCount(1, …) evidence rather than decoration). The count assertion stays at 2, unchanged, which is the point. The previous version is recorded in the test's docblock rather than quietly replaced.
    • The coverage pin was extended, not relaxed. test_each_registered_repository_covers_the_relations_its_review_found() now pins Product 17, Article 4, Page 2, product-category 4, blog-comment 3, blog-category 3, blog-tag 1. This module's configurator declares exactly ONE relation and it is excluded: translations targets Cms\Blog\Tag\Repository\MuiRepository, one final segment from the registered class-string and carrying no visibility flag. One declared, one excluded, zero own hops plus the one cross-module relation = 1 — the only entry in the pin with no own hop at all. 2 is wrong (it counts the module's whole configurator) and 0 would mean Article.tags stopped resolving onto the target. For this entry alone the coverage pin and the invariant's blind floor (count > 0) assert the same thing and fail together; that degenerate case is documented rather than tidied away, because the pin is what stops the number being edited upward to absorb an unreviewed relation, which the floor cannot see.
    • The per-entry blind floor still discriminates with seven entries. test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another() now expects the Article, Page, product-category, blog-comment, blog-category and blog-tag entries in the blind list while Product, which matched, stays out. Both expected arrays were updated to new exact values rather than the assertions loosened — a count, a subset check or a sort would delete exactly the per-entry discrimination property that test exists to hold. Its docblock records that seven is a ceiling for now rather than a waypoint: the next growth means a genuinely new scope class, and the extend-never-relax instruction has not softened because the known backlog is exhausted.
    • The failing case is demonstrated per target, not inherited. A new test_the_invariant_reports_a_blog_tag_relation_whose_visibility_scope_went_missing() drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy of Blog\Article.tags. Its sharpest negative control is Product.tags → Product\Tag\Tag\Repository\Repository, a real and genuinely unscoped upstream relation that shares the registered key's entire trailing Tag\Repository\Repository triple and differs only in the leading namespace, so any str_ends_with or trailing-triple comparison flags it and only the whole resolved class-string keeps it out — which is what the row pins. It also carries the same relation name, tags, which costs nothing to carry but guards a hypothetical rewrite rather than a reachable mutation: no code path resolves a target from a relation name. Cms\Blog\Tag.translations is pinned as a second unscoped control. A fifth row, Article.categories, is real and scoped, and pins the attribution rule with #659's roles inverted: since this target has no own relations, the same-configurator-different-target row is drawn from the configurator that declares the subject relation. Three of the fixture's five rows are keyed to Blog\Article's configurator and land on three different answers — two on blog-tag, one on blog-category, none on Article — which makes executable, from both directions, the rule that a relation is counted against the entry its TARGET resolves onto and never the entry its declaring configurator belongs to. It does not guard the coverage number; the assertions it literally executes are 2, 1 and 0. Verified by experiment: with the registry entry removed the new test fails (Failed asserting that actual size 0 matches expected size 1); with it present, green.
    • No production behaviour change. Test-only — src/, application/, ecommercen/ and database/ are untouched. Fork owners: if your custom/Domains copy of Cms\Blog\Article's RepositoryConfigurator predates #640, this guard now fails for you. Re-sync the copy against src/ — the fix is to restore the visibilityScope argument on tags, not to add an exemption.
  • [4.121.0] test(domains/support): register the blog-category repository in the visibility-scope positive invariant, bringing all three relations onto it inside the guard (Advisable-com/ecommercen#659)

    • What changed. #641 shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-null visibilityScope, unless explicitly exempted — and #655, #658, #656 and #657 added its second through fifth entries. The three relations reaching Cms\Blog\Category\Repository\Repository were still outside it: scoped by #640, with nothing asserting they stayed scoped. This delivery adds Cms\Blog\Category\Repository\Repository => BlogCategoryVisibilityScope as the sixth entry.
    • Zero exemptions and zero allow-list edits were needed. All three relations were already scoped by #640: the two self-referencing hops declared in the module's own configurator (children and parent) plus the cross-module Article.categories. All three relation keys were already pinned in test_only_the_reviewed_relations_declare_a_visibility_scope(). visibilityScopeExemptions(), matchingExemption(), withoutPsr4Root(), registeredTargetFor(), instantiate() and the allow-list guard with its pinned keys are identical, md5-verified at the base and in the working tree (line endings normalised first; the checkout is CRLF and the blob is LF, so a naive byte-compare reports every region as differing) — this delivery changes coverage membership, not mechanics.
    • The audit was done by resolved class-string, not by grep, and that mattered here. An escaped-FQCN sweep for this target misses the module's own two hops entirely, because both are declared as a bare self-referential Repository::class. A sweep for the source text CategoryRepository is worse than useless: that same alias names three different repositories across the tree — this target in Cms\Blog\Article's configurator, Product\Category in Product\Product's, and Event\Category in Event\Event's — so it returns two false positives while still missing the two namespace-local hops. Only resolving Relation::$relatedRepositoryClass through the test's own reflection walk finds all three and nothing else. Measured over the 200 relations the guard walks: 3 relations onto the target, all three with a non-null visibilityScope, across exactly 2 configurators (Cms\Blog\Article's and the module's own).
    • The coverage pin was extended, not relaxed — and the exclusion arithmetic is the trap on this entry. test_each_registered_repository_covers_the_relations_its_review_found() now pins Product 17, Article 4, Page 2, product-category 4, blog-comment 3, blog-category 3. This module's configurator declares FOUR relations, of which TWO are excluded: translations targets Cms\Blog\Category\Repository\MuiRepository, the module's own Mui class one final segment from the registered class-string and carrying no visibility flag; and articles targets Cms\Blog\Article\Repository\Repository — it is scoped (#640), but by ArticleVisibilityScope onto the Article repository, so it is counted against that entry, exactly as Product\Category.articles is. Four declared, two excluded, two own hops plus the one cross-module relation = 3. 4 and 5 are both wrong, in both directions. That number is guarded by the coverage pin itself — it is what fails if someone edits the 3 to a 4 — not by the new failing-case test, whose own assertions execute 2 and 1 and pin a different property (see below).
    • The per-entry blind floor still discriminates with six entries. test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another() now expects the Article, Page, product-category, blog-comment and blog-category entries in the blind list while Product, which matched, stays out. Both expected arrays were updated to new exact values rather than the assertions loosened — a count, a subset check or a sort would delete exactly the per-entry discrimination property that test exists to hold.
    • The failing case is demonstrated per target, not inherited. A new test_the_invariant_reports_a_blog_category_relation_whose_visibility_scope_went_missing() drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy of BlogCategory.children. Its sharpest negative control is Event\Event.categories, a real and genuinely unscoped upstream relation whose target shares the registered key's entire trailing Category\Repository\Repository triple and differs only in the leading namespace — while being declared through an import aliased to exactly the same CategoryRepository text the row above uses for the real target. A text sweep flags it; so does any str_ends_with or trailing-triple comparison. No other fixture in the file pins that shape (Page pins the neighbouring-final-segment shape, product-category the inserted-segment shape, blog-comment the substituted-middle-segment shape). translations is pinned as a second unscoped control. A fifth row, BlogCategory.articles, is real and scoped, and is there to pin the attribution rule: with translations pinned OUT of this entry and articles pinned ONTO the Article entry, the two count assertions together make executable — from both directions, which nothing else in the file asserts — the rule that a relation is attributed by the entry its TARGET resolves onto, never by the entry its DECLARING CONFIGURATOR belongs to. It does not guard the coverage number. #656 faced the identical shape (Product\Category.articles) and deliberately omitted it; including it here is the better call only because an omitted row cannot supply that second direction, and #660 should copy this shape. Verified by experiment: with the registry entry removed the new test fails (0 offenders reported where 1 is required); with it present, green.
    • The rule this target carries is OWN-FLAG, not ancestor-chain, and that is not an oversight to "align" with the product-category entry. BlogCategoryVisibilityScope narrows to blog_categories.is_active = 1 on the row's own flag. #640 measured the difference across 14 tenant databases — zero active-but-unreachable blog_categories rows, a taxonomy that is essentially flat (max depth 2 in 2 of 14 tenants, no nesting at all in the other 12) — where #588 found 42% of pharm16's product categories published-but-unreachable and #625 was ratified onto a BFS ancestor chain on the strength of it. The two scope classes share the word Category and nothing else. The accepted delta is stated on the scope class itself: an active child under an inactive parent would remain visible, measured as a non-occurrence rather than an impossibility, with the escalation path recorded if a tenant ever creates one.
    • The rule is = 1, never != 0. blog_categories.is_active is int(2) DEFAULT NULL, and NULL-flagged rows are deliberately withheld from storefront callers. Do not "tidy" the comparison.
    • This rule is a deliberate TIGHTENING over legacy, not parity — which is what a fork owner needs to read before resyncing. The legacy blog-category storefront never filters is_active at all: getCategoriesFront() applies only the conditions its caller supplies and every call site passes lang alone, while the model's two is_active occurrences emit the column into the returned array rather than filter on it. So inactive and NULL-flagged blog categories are reachable on the legacy storefront and stop being served over REST. That is intentional — the flag exists to hide rows and legacy simply never enforced it — but it is a behaviour change, not a formalization of existing behaviour. (The sibling blog-tag rule is the opposite, straight parity with legacy; #624's rest_api_versions entry records the two justifications separately and tells fork owners to read them separately.)
    • No fork-coverage caveat on this entry, and the absence is deliberate. #658's Page entry carries a restated KNOWN FORK-COVERAGE LIMITATION note because auditRequiredVisibilityScopes() once matched the target with a raw isset($registry[$target]) that did not strip the PSR-4 root, so a fork copying a whole Repository/ directory shifted a namespace-local target to Custom\… and was skipped outright. #661 (401043cc1) closed that gap. Both of this target's own hops are exactly that namespace-local shape, so copying #658's note here would be false, not merely redundant — both fork copy shapes are caught from the outset. #656 and #657 correctly shipped none either. With this entry registered, no namespace-local target remains pending.
    • The one remaining pending target stays off. Cms\Blog\Tag (#660) still needs its own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. It must also land last — registering it flips the Article.tags control row in #655's demonstration test into an offender. One target per issue.
    • No production code, no migration, no REST contract change, no new config key. Test-only.
  • [4.121.0] test(domains/support): register the CMS Page repository in the visibility-scope positive invariant, bringing the two recursive page-tree relations inside the guard (Advisable-com/ecommercen#658)

    • What changed. #641 shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-null visibilityScope, unless explicitly exempted — and #655 added its second entry. The two relations reaching the CMS Page repository, Page.parent and Page.children, were still outside it: scoped by #639, with nothing asserting they stayed scoped. This delivery adds Cms\Page\Repository\Repository => PageVisibilityScope as the third entry.
    • Zero exemptions and zero allow-list edits were needed. #639 had already scoped both hops, and both relation keys were already pinned in test_only_the_reviewed_relations_declare_a_visibility_scope(). visibilityScopeExemptions() and the allow-list's 27 pinned keys are byte-identical — this is a one-entry addition plus the counts and prose that quote it.
    • The coverage pin was extended, not relaxed. test_each_registered_repository_covers_the_relations_its_review_found() now pins Product 17, Article 4, Page 2. Page.translations is deliberately not in that 2: it targets Cms\Page\Repository\MuiRepository, a different class-string carrying no visibility flag, and the children hop being scoped is what keeps an unpublished node's categories_mui rows from ever materialising.
    • The per-entry blind floor still discriminates with three entries. test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another() now expects both the Article and the Page entries in the blind list while Product, which matched, stays out. The expected values were updated rather than the assertions loosened — a subset check or a sort would delete exactly the property that test exists to hold.
    • The failing case is demonstrated per target, not inherited. A new test_the_invariant_reports_a_page_relation_whose_visibility_scope_went_missing() drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy of Page.children. Verified by experiment: with both visibilityScope arguments deleted from src/Domains/Cms/Page/Repository/RepositoryConfigurator.php, the invariant is green without this registry entry and red with it, naming both relations.
    • A known bound on the fork-facing half, recorded in the registry entry's own comment (#661). auditRequiredVisibilityScopes() selects relations with a raw isset($registry[$target]) and does not strip the PSR-4 root, while matchingExemption() deliberately does. Both Page relations are declared self-referentially — a bare Repository::class resolved inside the Page module's own namespace — so a fork that copies the whole Repository/ directory resolves them to Custom\…\Cms\Page\Repository\Repository, which is not a registry key, and those copies are skipped outright. A fork that copies only the configurator, leaving the reference on the upstream FQCN, is caught. Upstream coverage here is complete (2 of 2); fork coverage is partial. Registering the target is still strictly better than leaving it out, and #661 tracks the fix separately because normalising the root changes the routine every entry shares.
    • The other four pending targets stay off. Product\Category, Cms\Blog\Comment, Cms\Blog\Category and Cms\Blog\Tag each still need their own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. One target per issue.
    • No production code, no migration, no REST contract change, no new config key. Cms\Page\WriteService::update()'s Relation::VISIBILITY_EXEMPT_ALL grant (#639) is untouched and unaffected — the invariant asserts on a declaration in a configurator, the grant is a runtime argument at a call site, and neither can satisfy or defeat the other. Test-only.
  • [4.121.0] test(domains/support): register the blog-comment repository in the visibility-scope positive invariant, bringing all three relations onto it inside the guard (Advisable-com/ecommercen#657)

    • What changed. #641 shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-null visibilityScope, unless explicitly exempted — and #655, #658 and #656 added its second, third and fourth entries. The three relations reaching Cms\Blog\Comment\Repository\Repository were still outside it: scoped by #616, with nothing asserting they stayed scoped. This delivery adds Cms\Blog\Comment\Repository\Repository => CommentVisibilityScope as the fifth entry.
    • Zero exemptions and zero allow-list edits were needed. All three relations were already scoped by #616: the two self-referencing hops declared in the module's own configurator (parent and children) plus the cross-module Article.comments. Both of the module's own relation keys were already pinned in test_only_the_reviewed_relations_declare_a_visibility_scope(). visibilityScopeExemptions(), matchingExemption(), withoutPsr4Root(), registeredTargetFor() and the allow-list guard with its pinned keys are identical, md5-verified at the base and in the working tree (line endings normalised; the checkout is CRLF) — this delivery changes coverage membership, not mechanics.
    • The audit was done by resolved class-string, not by grep, and that mattered here too. An escaped-FQCN sweep for this target misses the module's own two hops entirely, because both are declared as a bare self-referential Repository::class. A sweep for the source text CommentRepository finds only the third relation, Article.comments, which reaches the target through the aliased import use ...Comment\Repository\Repository as CommentRepository. Only resolving Relation::$relatedRepositoryClass through the test's own reflection walk finds all three and nothing else. Measured: 3 relations, all three with a non-null visibilityScope, across exactly 2 configurators (Cms\Blog\Article's and the module's own).
    • The coverage pin was extended, not relaxed. test_each_registered_repository_covers_the_relations_its_review_found() now pins Product 17, Article 4, Page 2, product-category 4, blog-comment 3. No relation is excluded from that 3, and unlike every entry above it that is not an omission to go looking for. This module's configurator declares exactly two relations and both are counted; the module ships no translations hop and no MuiRepository.php at all, so the same-module-different-final-segment exclusion the Page and product-category entries each have to draw simply has no instance here. Which is why 2 would be wrong — it drops the cross-module Article.comments, the only relation onto this target declared outside the module — and why any number above 3 would be wrong: there is no fourth relation to find.
    • The per-entry blind floor still discriminates with five entries. test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another() now expects the Article, Page, product-category and blog-comment entries in the blind list while Product, which matched, stays out. Both expected arrays were updated to new exact values rather than the assertions loosened — a count, a subset check or a sort would delete exactly the per-entry discrimination property that test exists to hold.
    • The failing case is demonstrated per target, not inherited. A new test_the_invariant_reports_a_blog_comment_relation_whose_visibility_scope_went_missing() drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy of Comment.children. It pins two negative controls, both real upstream relations and both genuinely unscoped so a loose matcher would report them: Article.author, whose target shares the registered key's whole Cms\Blog\ prefix and its whole trailing Repository\Repository pair while differing only in the middle segment — a shape no other fixture in the file pins — and Article.translations, a MuiRepository one final segment from a different registry key. Verified by experiment: with the registry entry removed the new test fails (0 offenders reported where 1 is required); with it present, green.
    • The registry stays names-only, and this target is the recorded reason it is. CommentVisibilityScope is the only static scope in the tree — a class-level relationScope() over a protected const TABLE — so it is never constructed and never appears as a constructor parameter anywhere (a tree-wide search for CommentVisibilityScope $ returns nothing). A registry that instantiated its values would need a per-class special case for exactly this one. It also means instantiate() needed no change: its scope-stubbing branch keys on a constructor parameter type, so it never sees this class at all. Confirmed rather than assumed, and deliberately not touched "for symmetry".
    • No fork-coverage caveat on this entry, and the absence is deliberate. #658's Page entry carries a restated KNOWN FORK-COVERAGE LIMITATION note because auditRequiredVisibilityScopes() once matched the target with a raw isset($registry[$target]) that did not strip the PSR-4 root, so a fork copying a whole Repository/ directory shifted a namespace-local target to Custom\… and was skipped outright. #661 (401043cc1) closed that gap. Both of this target's own hops are exactly that namespace-local shape, so copying #658's note here would be false, not merely redundant — both fork copy shapes are caught from the outset. #656 correctly shipped none either.
    • The other two pending targets stay off. Cms\Blog\Category (#659) and Cms\Blog\Tag (#660) each still need their own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. One target per issue.
    • No production code, no migration, no REST contract change, no new config key. Test-only.
  • [4.121.0] test(domains/support): register the product-category repository in the visibility-scope positive invariant, bringing all four relations onto it inside the guard (Advisable-com/ecommercen#656)

    • What changed. #641 shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-null visibilityScope, unless explicitly exempted — and #655 and #658 added its second and third entries. The four relations reaching Product\Category\Repository\Repository were still outside it: scoped by #625 and #588, with nothing asserting they stayed scoped. This delivery adds Product\Category\Repository\Repository => CategoryVisibilityScope as the fourth entry.
    • Zero exemptions and zero allow-list edits were needed. All four relations were already scoped: the three hops declared in the module's own configurator (parent, children and relativeCategories, all #625) plus the cross-module Product.categories (#588). All four relation keys were already pinned in test_only_the_reviewed_relations_declare_a_visibility_scope(). visibilityScopeExemptions(), matchingExemption(), withoutPsr4Root(), registeredTargetFor() and the allow-list guard with its 27 pinned keys are byte-identical — this delivery changes coverage membership, not mechanics.
    • The audit was done by resolved class-string, not by grep, and that mattered. An escaped-FQCN sweep for this target returns zero hits, because the three own hops are declared as a bare self-referential Repository::class. A sweep for the CategoryRepository alias returns false positives, because that alias names three different repositories across the tree (Product\Category in Product\Product's configurator, Event\Category in Event\Event's, Cms\Blog\Category in Blog\Article's). Only resolving Relation::$relatedRepositoryClass finds all four and nothing else.
    • The coverage pin was extended, not relaxed. test_each_registered_repository_covers_the_relations_its_review_found() now pins Product 17, Article 4, Page 2, product-category 4. This module's configurator declares SIX relations, so THREE of them are deliberately not in that 4, and none of the three is a miscount: articles targets the Article repository and is counted against that entry; tagGroups targets Product\Tag\Category\Repository\Repository, which carries no visibility flag at all; and translations targets Product\Category\Repository\MuiRepository, a different class-string carrying no visibility flag either — so 5, 6 and 7 would all be wrong.
    • The per-entry blind floor still discriminates with four entries. test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another() now expects the Article, Page and product-category entries in the blind list while Product, which matched, stays out. Both expected arrays were updated to new exact values rather than the assertions loosened — a count, a subset check or a sort would delete exactly the per-entry discrimination property that test exists to hold.
    • The failing case is demonstrated per target, not inherited. A new test_the_invariant_reports_a_product_category_relation_whose_visibility_scope_went_missing() drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy of Product\Category.children. It pins two negative controls specific to this target, both unscoped so a loose matcher would report them: tagGroups, whose target differs from the registered key only by an inserted Tag\ segment while ending in the same three segments, and translations, one final segment apart on MuiRepository. Verified by experiment: with the three visibilityScope: arguments deleted from src/Domains/Product/Category/Repository/RepositoryConfigurator.php, the invariant is green without this registry entry and red with it, naming all three hops.
    • No fork-coverage caveat on this entry, and the absence is deliberate. #658's Page entry originally carried a KNOWN FORK-COVERAGE LIMITATION note because auditRequiredVisibilityScopes() matched the target with a raw isset($registry[$target]) that did not strip the PSR-4 root, so a fork copying a whole Repository/ directory shifted a namespace-local target to Custom\… and was skipped outright. #661 (401043cc1) closed that gap before this delivery, so both fork copy shapes are caught here from the outset — the copy that keeps the reference on the upstream FQCN and the whole-directory copy that does not. Copying #658's note onto this entry would have been false as written.
    • The other three pending targets stay off. Cms\Blog\Comment (#657), Cms\Blog\Category (#659) and Cms\Blog\Tag (#660) each still need their own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. One target per issue.
    • No production code, no migration, no REST contract change, no new config key. CategoryVisibilityScope is DB-backed — it takes a CI_DB_query_builder and walks the category tree — and that is irrelevant to this change: the registry maps class names and never constructs anything, and the test harness mocks scopes structurally off the presence of a relationScope() method. Test-only.
  • [4.121.0] test(domains/support): register the blog Article repository in the visibility-scope positive invariant, bringing the four articles relations inside the guard for the first time (Advisable-com/ecommercen#655)

    • What changed. #641 shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-null visibilityScope, unless explicitly exempted — but seeded its registry (RelationConfigurationTest::scopeBearingTargetRepositories()) with exactly one active entry, the Product repository. The four relations reaching the blog Article repository therefore sat outside the invariant entirely: they were scoped, and nothing asserted they stayed scoped. This delivery adds Cms\Blog\Article\Repository\Repository => ArticleVisibilityScope as the second entry.
    • Zero exemptions were needed. All four relations were already scoped by earlier deliveries — BlogAuthor.articles (#653), BlogCategory.articles (#640), Product\Category.articles (#625) and Product.articles (#653). Registering the repository was a one-line addition with no behaviour change and no new entry in visibilityScopeExemptions(), which is the shape #641 built the registry for.
    • A new coverage pin, because "registered" is not "covered". test_each_registered_repository_covers_the_relations_its_review_found() asserts the exact per-target relation count each registry entry matches — Product 17, Article 4. The invariant's existing per-entry blind floor only asks for > 0, which catches a renamed class-string but not a partial match: an entry matching one of its four relations passes the floor while three sit outside the guard. That is not hypothetical — it is precisely the state this issue fixed, one registry entry with four relations unguarded and the whole suite green. It is a separate test rather than extra assertions on the invariant, so that ordinary relation additions never edit the assertion the safety property lives in.
    • The other five pending targets stay off. Product\Category, Cms\Blog\Comment, Cms\Page, Cms\Blog\Category and Cms\Blog\Tag each still need their own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. One target per issue.
    • The allow-list guard test_only_the_reviewed_relations_declare_a_visibility_scope() and its 27 pinned keys are untouched. The two guards remain complementary — the allow-list catches an unreviewed addition, the invariant catches a removal.
    • No production code, no migration, no REST contract change, no new config key. Test-only.
  • [4.121.0] fix(rest): scope Product.articles and BlogAuthor.articles with #640's ArticleVisibilityScope, closing the last two guest-reachable relations that returned the complete pre-publication body of every draft article (Advisable-com/ecommercen#653)

    • The two relations. Product.articles (src/Domains/Product/Product/Repository/RepositoryConfigurator.php, MANY_TO_MANY via pivot product_blog) and BlogAuthor.articles (src/Domains/Cms/Blog/Author/Repository/RepositoryConfigurator.php, ONE_TO_MANY on blog_author_id) now carry ArticleVisibilityScope (blog.is_published = 1) — the existing class #640 built and #625 already reused cross-module, not a new one. Both owning endpoints (GET /rest/product/product, GET /rest/cms/blog/author) are auth => guest on index/show/item and declare no relations allow-list, which could not have closed this anyway: RelationFilterMiddleware matches only the first ?with= hop.
    • Severity. blog.is_published is NOT NULL DEFAULT 0, so every draft ever created was in the exposed set. BlogArticleMuiResource emits blog_mui.description with no isBackend() gate, so what leaked was the full article body, not merely row existence. index() embeds the relation on every returned row, so both were bulk-enumerable in a single unauthenticated request. Product.articles sits on the highest-traffic guest surface in the API — GET /rest/product/product?with=articles.translations — and was the live path: Rest\Product\Resources\Product\Resource serialises articles unconditionally. BlogAuthor.articles was not serialised by upstream's own Resource (translations only), so upstream's exposure there was latent rather than live — but the relation is advertised in that endpoint's OpenAPI x-relations metadata as available, so a client fork that serialises it from its own Resource subclass was live-exposed all along. Declaring the rule on the relation is what makes serialising it safe for anyone to do later.
    • This is the third and fourth call site for ArticleVisibilityScope and completes the set: #640 built the scope and is its first consumer (BlogCategory.articles), #625 reused it cross-module from Product\Category, and every article-targeting relation in the tree is now scoped. #641 shipped the structural positive-invariant guard for this class of omission, but it is currently inert for these two relations — its scopeBearingTargetRepositories() registry lists only the Product repository, and enabling the Article repository there is a separate later step.
    • No production behaviour change for backend callers: the central Relation::VISIBILITY_EXEMPT_ALL grant still returns drafts to admin screens through both relations, so admin product/author edit views are unaffected.
    • No write-path edge — both owning WriteServices load ['translations'] only, so unlike #639 no VISIBILITY_EXEMPT_ALL was needed on a server-side relation list.
    • Files: the two configurators; tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php (declared-visibility-scope inventory 25 → 27); a new tests/Unit/Domains/Cms/Blog/Article/ArticleRelationVisibilityScopeTest.php; application/config/rest_api_versions.php carries its own 1.X entry, authored and maintained separately from this fragment.
  • [4.121.0] fix(rest): bound the ?limit query parameter for storefront list reads — new rest_max_page_size ceiling, default 1000 (Advisable-com/ecommercen#652, ceiling value set by #664)

    • Why. A ?limit that was actually supplied was never checked against a maximum. GET /rest/product/product?limit=47419 returned 47419 rows, on roughly 114 list endpoints, most of them guest-reachable — a single anonymous request was a resource-exhaustion lever, with no authentication, rate limit or role check in front of it.
    • What the defect was NOT — the framing matters, because the obvious fix is the wrong one. The default was already correct and is completely unchanged. GenerateListRequest::getLimit() is $this->request->get('limit') ?: self::PER_PAGE, so an omitted limit yields 15, and ?limit=0 / ?limit= are falsy and yield 15 too; Pagination then independently guards $perPage > 0 ? $perPage : 15 and always emits a LIMIT. There was never an unbounded path from omitting the parameter. getLimit() and PER_PAGE are therefore untouched, and so is Pagination — this bounds a supplied value and nothing else.
    • The change. One clamp in HandlesRestfulActions::buildListRequest(), the single construction point shared by index(), show() and item() across the 110 controllers that extend HandlesRestfulActions. ListRequest::$perPage is a public, non-readonly promoted property, so the generated request is clamped on the way out — no new plumbing through GenerateListRequest, which has no ResourceContext to branch on anyway.
    • DECISION — clamp, never reject. No exception, no 4xx, no new error field. The platform has no 4xx path for a bad list value anywhere (an unparseable filter value is dropped, not refused), and refusing a limit that is accepted today would break live callers outright instead of degrading them. A request for 47419 rows returns HTTP 200 with 1000 rows.
    • DECISION — storefront only; backend callers are exempt. Discriminated on ResourceContext::isBackend(), mirroring Product::enforceStorefrontProductScope() — an established pattern, not a new one. An admin export or a back-office grid legitimately pulls large pages and is authenticated and role-gated; a guest is neither. A customer token is still a storefront caller. Fail-closed: a route that never calls setResourceContext() has a null context, and null is not an exemption.
    • DECISION — the ceiling is 1000, and it is configuration, not a literal. New key rest_max_page_size in application/config/app.php, sat beside products_list_limit / products_list_max because it is the same family of setting on a different surface (REST vs the rendered storefront's per-page selector). 1000 specifically (Advisable-com/ecommercen#664), superseding an earlier 200 that never shipped. Sizing a platform-wide ceiling to the page size one headless consumer happened to use was the wrong basis: it turns one client's paging strategy into every shop's bound, and it breaks any other consumer that reads a reference list in a single request. 1000 is set high enough that a legitimate one-shot bulk read — a reference list, a facet computation, a data sync — completes without paging. The bound is loosened, not removed, and that is a deliberate trade-off: the ceiling exists as resource-exhaustion hardening, so 1000 gives back most of the mitigation 200 provided while still refusing ?limit=47419 a 47419-row page. A shop wanting the tighter bound sets rest_max_page_size to it locally, and maxPageSize() stays protected for a fork overriding the read path outright — so do not "fix" 1000 back to 200 as an apparent regression. Read lazily through get_instance() at request time — never in a constructor — the same seam constraint ListingConfigResolver documents.
    • DECISION — the config read is fail-SAFE, not fail-open. An absent key, a non-numeric value, a zero or negative value, or no reachable CI instance at all all resolve to 1000 — the HandlesRestfulActions::DEFAULT_MAX_PAGE_SIZE constant, deliberately kept equal to the shipped config default and pinned to it by a test so the two cannot drift apart. application/config/app.php is a client-owned file in a fork, so a fork that has not merged the new line must keep the ceiling rather than silently lose it. Losing the ceiling is the exact failure this change exists to prevent, so no degenerate input may resolve to "unbounded".
    • The bypass that was closed — the part most likely to be missed. A clamp in buildListRequest() does not automatically reach every list endpoint: three controllers bypassed the seam with the raw (new $this->listRequestClass())->generate($this->input) construction, which opts an endpoint out of every server-side list constraint (forced and denied filter/sort keys, the backend relation-visibility exemption, and now the ceiling) with no error and a green boot. Admin\Role::index() was a real LIST read and has been migrated to $this->buildListRequest() — behaviour-neutral there, since Role registers no forced or denied keys and its RepositoryConfigurator is the NullRelationConfigurator, and the route is backend-only so the ceiling never applies to it anyway. The practical gap was nil today; it becomes a real hole the moment a guest endpoint copies that shape, which is why it was closed rather than merely noted. Order::show() and Wishlist::show() keep the raw construction deliberately — both are per-row fetches by primary key, where a page size is not a meaningful concept.
    • Tests: tests/Unit/Rest/Support/StorefrontPageSizeCeilingTest.php (new — guest and customer clamped, backend exempt, null context clamped, the ceiling read from config including a numeric-string value, seven degenerate config values all falling back to the DEFAULT_MAX_PAGE_SIZE constant, no-CI-instance falling back to it too, a drift guard asserting the shipped rest_max_page_size default in application/config/app.php equals that constant, pagination.per_page reporting the clamped size, and the unchanged-default regressions: omitted / empty / zero limit still 15, under-ceiling and exactly-at-ceiling passing through untouched) and tests/Unit/Rest/Support/ListRequestConstructionGuardTest.php (new — a static call-shape guard asserting that no controller outside HandlesRestfulActions touches $this->listRequestClass, with the two per-row show() branches allowlisted and their allowlist entries checked for staleness, plus a positive assertion that Admin\Role::index() routes through the shared seam).
  • [4.121.0] fix(lang): stop t() / ExternalLang::line() fatalling on a format/argument mismatch; fix every cross-locale translation defect that was triggering it (Advisable-com/ecommercen#651)

    • The root cause, one shape, two copies. Both helpers handed an unvalidated translation string straight to vsprintf(). On PHP 8, a format string whose %-specifiers don't line up with the supplied argument count (or that contains a bare %) is an uncaught ValueError, which white-pages the whole request. t() and ExternalLang::line() were the same six lines duplicated, and had drifted into two copies of the same bug.
    • The merchant-visible headline. The admin products batch-update page was down in every shipped locale except greek — including a default english install. Product create/update/clone/ERP-add and the category discount pages were down in italian and spanish. Order and product batch updates were down in italian.
    • The fix: degrade instead of fatal. Both helpers now delegate to a new shared AdvLangFormatter (ecommercen/libraries/AdvLangFormatter.php). A zero-argument call skips vsprintf() entirely. Anything else is wrapped: a mismatch logs the offending key with the expected-vs-supplied argument count and renders the unformatted text instead of throwing. Argument values are never logged — they carry order serials, names, and addresses.
    • The data fix. Every cross-locale inconsistency in the translation data is fixed — all six shipped language families now declare an identical required argument count per key across all eight locales.
    • Customer-facing, and not in the original report: the order-cancellation email (eshop.front.mail.order.update.order.has.been.canceled) carried a bare % in chinese, french, german and russian, raising Unknown format specifier — unsatisfiable at any argument count. A merchant running any of those four locales could not send an order-cancellation email at all.
    • The gift-card SMS (giftCard.sms.message) was broken in 7 of 8 locales. Five locales (chinese, english, french, german, russian) lacked the key entirely, so customers received an SMS whose body was the literal text giftCard.sms.message — silently, with no error. Spanish declared four placeholders against three arguments and fatalled. Italian ordered its placeholders wrongly, so the customer received the site URL as the amount and the coupon code as the site. All eight locales now carry the message and use positional %1$s specifiers.
    • Also customer-facing: the review-accepted, review-rejected and blog-comment-accepted emails (eshop.front.mail.conclusion.of.review, eshop.front.mail.we.remain.at.your.side) now name the shop in chinese, english, french, german and russian. Those five locales previously rendered a generic phrase such as "its visitors" where greek, italian and spanish already named the shop; the wording is now aligned across all eight.
    • The spanish storefront registration block (customer.register.info.text.home) declared two placeholders against a one-argument call site, fatalling for logged-out visitors in spanish.
  • [4.121.0] refactor(domains): consolidate the per-filter dispatch of all 114 domain Services into one shared implementation with a granular override seam (Advisable-com/ecommercen#650)

    • What was duplicated. Every domain Service carried its own private buildSpecifications() — the filter loop, the match ($filter->type…) operator mapping, the NotEmpty branch, the root-sort loop, pagination and the WithRelations tail. 114 copies in 19 slightly different variants, 108 of them repeating the same three-arm operator match. Changing one operator for one endpoint meant copying all 39 lines, and there was no smaller unit to override.
    • What replaces it. Advisable\Domains\Support\Service\BuildsFilterSpecifications (a trait — this layer composes, it has no base Service class) owns the whole loop, and Advisable\Domains\Support\Request\QueryListBuilder\FilterOperatorMap owns the one filter-type → SQL-operator mapping behind FilterOperatorMapInterface. Zero Services now declare buildSpecifications(); the security-critical WithRelations(..., $listRequest->visibilityExemptions) line exists in exactly one place instead of 114.
    • The silent-fallback defect is gone. Each of the 108 copies ended in default => '=', so a FilterRequestType case a Service did not map degraded to an exact match with no error and no failing test — ?filter[priceGt]=0 compiled to price = 0 and returned precisely the rows the caller asked to exclude (#642). The shared match is exhaustive with no default arm, so an unmapped case throws instead, and a new static guard fails in CI before it can reach a query.
    • No behaviour change on any endpoint. Asserted on emitted SQL operators per Service, not inferred from a green suite: /rest/product/product — the one endpoint that deliberately diverges from the shared mapping — still applies = to every root filter except its four one-sided price comparisons; the one Between declaration and the four comparison declarations keep their operators; the four CMS Services that previously had no operator dispatch at all are unchanged because every root filter they declare is Exact; and the two CMS root keys whose declaration was corrected (below) keep the exact matching they always had.
    • Two filter DECLARATIONS corrected to match their long-standing behaviour — no response changes. filter[title] on GET /rest/cms/builder (+ /item) and filter[bannerImage] on GET /rest/cms/page (+ /item) were declared FilterRequestType::Partial while the code had always applied an exact match — the Services emitted a bare two-argument Filter, taking Filter::__construct's $operator = '=' default, so the declared type never selected the operator on those keys. Both are now declared Exact, which makes the declaration, the OpenAPI description and the machine-readable x-filters token all agree with the behaviour that already shipped. Nothing over the wire changes: both keys still match exactly, still accept a comma-separated list as a WHERE IN, and the two filterOperator() overrides that had been holding that line are retired as redundant. The translation-relation Partial keys on the same endpoints (name.{locale}, metaTitle.{locale}, …) are untouched — they route to FilterByTranslation, which applies a real LIKE '%…%'.
    • Advisable\Domains\Support\Service\HandlesNotEmptyFilters is removed; its two methods now live on BuildsFilterSpecifications, and the NotEmpty dispatch is inherited rather than restated in every Service.
  • [4.121.0] fix(rest): declare filter[vendorCode] on /rest/product/product as exact, matching what it has always run (Advisable-com/ecommercen#649)

    • The documentation was wrong, not the behaviour. filter[vendorCode] was declared — and published, in the x-filters machine-readable spec and the human-readable OA\Parameter description — as a partial (substring) match from the day the field was added. It has never actually been one. This fix corrects the declaration and the docs to match what has always shipped; it changes no behaviour. A client that built a substring-matching expectation, or a client-side workaround, from the published spec was never actually getting a substring match — there is nothing to migrate, but it's worth checking whether that assumption shaped anything on your end.
    • Why it was inert. Product\Product\Service::filterSpecification() builds new Filter($filter->field, $filter->values()) for vendorCode — no operator argument — which takes Filter::__construct's $operator = '=' default. Its filterOperator() override reads $filter->type in a match for a non-relation column, but every type other than the four one-sided comparisons (Gte, Gt, Lte, Lt) maps to '=' — so Exact and Partial both resolve to the same operator-less new Filter($filter->field, $filter->values()) call and the same predicate, and the Partial declaration did nothing for its entire life. vendorCode was the only non-relation Product filter declared Partial; the genuinely partial fields (name.{locale}, metaTitle.{locale}, barcode) are routed through FilterByTranslation / FilterByBarcode, which do receive and use the type.
    • The three corrections:
      • src/Domains/Product/Product/ListRequest.php:26 — 'type' => FilterRequestType::Partial → FilterRequestType::Exact.
      • src/Rest/Product/Controllers/Product.php:39 (x-filters) — 'type' => 'partial' → 'exact'.
      • src/Rest/Product/Controllers/Product.php:89 (OA\Parameter) — 'Filter by vendor code (partial)' → 'Filter by vendor code (exact match)'.
    • A scaffolding slip, not a decision: the field was declared Partial since bc1ac36ce1 (2026-01-02), alongside genuinely free-text fields in the same batch, and a14a0ab024 later transcribed it into the published x-filters spec — so it became a published contract without anyone deciding it should be one.
    • Tests: tests/Integration/Domains/Product/Product/ServiceVendorCodeFilterTest.php (new, 2 tests). Fixture rows vendor_code = 'ABC', 'ABC123', 'XABC' are chosen so a LIKE '%ABC%' would match all three while = 'ABC' matches only one, so the test discriminates rather than merely passing. The second test builds the same FilterRequest once with type: Exact and once with type: Partial, both through Service::all(), and asserts identical result sets — the test that fires if the generic branch ever starts reading $filter->type.
  • [4.121.0] feat(rest/cms): add a guest-readable reel showcase endpoint, GET /rest/cms/reel (Advisable-com/ecommercen#648)

    • The gap. The platform has two unrelated video showcases. GET /rest/cms/video projects the legacy YouTube video table; the merchant's own CDN-hosted reels — the content behind the homepage reel strip and the /reels page — live in video_streams and had no REST projection at all. A headless storefront had no way to render the merchant's actual reels and could only fall back to the YouTube table, which is entirely different content.
    • The change. GET /rest/cms/reel (plus /item, /{id} and their (\w{2})/ locale-prefixed twins) serves the merchant's active, promoted, fully-transcoded reels. Guest-readable, matching the two legacy read paths it replaces. Each reel carries its localized name (resolved from video_streams_mui for the request language — what the locale route twins select), server-resolved playback URLs (playlistUrl, playUrl, thumbnail, preview) and productIds — the ordered ids of the products it features, in the merchant's authored sequence (video_streams_lp.order). Default order is newest first (-createdAt, homepage parity); sort=-updatedAt reproduces the /reels page ordering. One filter, filter[id] (exact).
    • Row invariant, not a filter. Every read — collection and fetch-by-id alike — is scoped to active = 1 AND is_promo = 1 AND status = 'FINISHED' AND cdn_video_id IS NOT NULL, identical to both legacy read paths. It is enforced in the repository rather than as a client-settable filter, so /rest/cms/reel/{id} on a row failing any clause returns 404, indistinguishable from a missing row.
    • Products are ids, not an embed. There is deliberately no ?with=products: the platform's many-to-many relation loader cannot carry a pivot-table ordering, so an embed would have silently scrambled the merchant-authored sequence. productIds also lists only products a shopper can actually reach — active and not soft-deleted — so every id a client fetches from /rest/product/product?filter[id]=... is guaranteed to come back for that same caller. A reel whose merchant later deactivates a featured product simply lists one id fewer, and the remaining ids keep their authored order; zero-priced products are deliberately still listed, since they are predominantly gift SKUs a storefront must render. Backend callers receive every id in the lookup table, unscoped.
    • Playback URLs degrade, never error. All four URL fields come back null — with a 200 and every other field intact — when no stream provider is configured or reachable. A client must treat a null URL as "not playable yet", never as an error.
    • Two small, purely additive companions. GET /rest/features gains a videoShowcase boolean (discovery-only — no guarded entry, since legacy uses the flag to hide the storefront section, never to 404 the data). GET /rest/storefront-config gains a fourth section, videoShowcase, carrying homepageLimit (int, default 8 — the same literal legacy falls back to). It is advisory, not enforced: pass it back as ?limit; the endpoint applies no hidden cap of its own, and the /reels page deliberately does not inherit it.
    • Read-only by design: admin CRUD over reels, including the CDN upload lifecycle, stays in the legacy admin module.
    • Tests: tests/Integration/Domains/Cms/Reel/RepositoryTest.php and ServiceTest.php; tests/Unit/Domains/Cms/Reel/ServiceDecorationTest.php; StorefrontConfigProviderTest extended to the fourth section; tests/Unit/Domains/StorefrontConfig/VideoShowcase/VideoShowcaseConfigResolverTest.php.
  • [4.121.0] fix(domains): bind the BETWEEN filter bounds instead of concatenating them (Advisable-com/ecommercen#645)

    • Why. Filter::apply()'s case 'BETWEEN' (src/Domains/Support/Repository/Specification/Filter.php) built ONE SQL condition string of the form field BETWEEN 'lower' AND 'upper' out of the raw client value. CodeIgniter protects the identifier in that position but passes an already-quoted literal through untouched, so neither bound was escaped nor bound.
    • Reachable with no credentials. filter[inDates] on GET /rest/cms/blog/article (index, /item, /{id}, plus locale-prefixed twins) is the platform's only FilterRequestType::Between key, and those actions are all auth => guest.
    • It composed with the forced storefront row scope #624 gave this same endpoint — which is why it was p0, not a nuisance. A payload of the 1' OR 1=1 -- ,2026-12-31 shape escaped its literal, and because SQL binds AND tighter than OR the whole WHERE read as (is_published = 1 AND blog_date >= ...) OR (1 = 1) — always true. The forced blog.is_published = 1 leg was bypassed, so one unauthenticated request could enumerate every unpublished article, bodies included (BlogArticleMuiResource emits blog_mui.description with no isBackend() gate).
    • The fix. The key now compiles to the inclusive pair field >= min AND field &lt;= max — two ordinary bound where() calls, each value escaped by the driver as a separate literal — and the BETWEEN keyword is no longer emitted. MySQL and MariaDB define expr BETWEEN min AND max as exactly that conjunction, three-valued NULL propagation included, so this is a rewrite of the emitted SQL and not of the semantics: row membership does not move. A well-formed range returns identical rows with identical inclusivity at both ends, a NULL blog_date is still dropped, and a date-only upper bound still coerces to midnight exactly as before. No route, response field, filter key, sort key, auth rule, policy, status code or response shape changes anywhere, and backend callers are entirely unaffected — the fix is in how the bounds are bound, not in what is filtered or by whom.
    • Deliberately preserved: the fail-open on a malformed value. A filter[inDates] that is not exactly two comma-separated bounds — one bound, or three or more — continues to emit no clause at all, and so continues to widen the read rather than being rejected. This is a decision, not an oversight: the platform has no 4xx path for a malformed filter value (a throw there would 500 a guest-readable endpoint), and inferring a one-sided bound from a single value would narrow a live guest-readable result set — a behaviour change, not a security fix. A null bound (e.g. [null, '2026-12-31']) is treated the same way, for a second, concrete reason: where('col >=', null) does not compile to col >= NULL — CI3's _wh() rewrites it through its IS-NULL branch into the malformed col > IS NULL — so "no bound supplied" has to mean no clause emitted at all. "Unchanged" here scopes to request-borne values, which is every value reachable through the API — FilterRequest::values() is a bare explode(), so both bounds always arrive as strings and a null bound cannot occur. A caller that constructs Filter directly with a null bound does see a flip in direction: that pair used to emit the narrow field BETWEEN '' AND 'upper', matching essentially no rows, and now emits no clause at all — wide. Both outcomes are correct for "no bound supplied"; a fork constructing Filter itself is the only caller positioned to notice.
    • Tests: tests/Unit/Domains/Support/Repository/Specification/FilterRangeOperatorTest.php gains a BETWEEN section (shape-level — the inclusive pair, the fail-open guard including both null-bound cases, and that neither bound ever reaches the key). tests/Integration/Domains/Support/Repository/Specification/FilterRangeOperatorSqlTest.php gains a BETWEEN section against compiled SQL over a DECIMAL column, including the regression test for the exact ... OR '1'='1' / comment-terminator payloads that used to break out of the literal. New tests/Integration/Domains/Cms/Blog/Article/ServiceInDatesFilterTest.php (11 tests) exercises the real, only live consumer end to end — a DATETIME column with a NULL-dated fixture row, and the composed bypass: a hostile inDates payload proves the forced is_published = 1 scope from #624 now survives alongside it. tests/Unit/Rest/Cms/Controllers/CmsStorefrontRowScopeTest.php gains 4 tests pinning that same composed guarantee at the controller/ListRequest level.
  • [4.121.0] feat(rest/storefront-config): expose the free-shipping banner threshold as a new shipping section (Advisable-com/ecommercen#643)

    • Why. ESHOP.TRANS_COST_LIMIT — the "free shipping over €X" promo-banner figure a merchant edits in admin — was not reachable by a headless storefront. GET /rest/storefront-config exposed only listing and loyalty, so a headless client had no option but to hardcode the number at build time and let it drift. Measured on the WeCare tenant: the legacy storefront rendered 65.00 from the registry while a headless app hardcoded 49, promising free shipping €16 earlier than the merchant actually gives it, with no way to read the real figure off the wire.
    • The change. A third curated section, shipping, carrying exactly one key: freeShippingThreshold (float, in the shop currency). Read live from the registry so an admin edit applies with no rebuild. Purely additive — listing and loyalty are untouched, no existing key changes name, type or shape, and the endpoint stays guest, read-only and global.
    • DISPLAY VALUE ONLY — the point a consumer most needs to get right. It must never be quoted to a customer as a price, and never used to compute, predict or validate a shipping cost. The thresholds that decide what a customer is actually charged are the per-transporter and per-country rows in transporters_options_pricing — a separate store that merely shares the TRANS_COST_LIMIT name — applied by POST /rest/checkout/shipping and POST /rest/checkout/totals. A cart clearing the banner threshold can still be charged shipping, because the chosen courier sets its own limit. This mirrors the warning already carried on Advisable\Domains\Checkout\ShippingCalculator, which is structurally unable to read the registry key (it has no Registry collaborator) and stays that way.
    • DECISION — a scalar scoped to the literal 'GR', not a per-country map and explicitly not config('default_country'). Registry::value()'s third argument is the lang column, which this key uses to hold a country code (an existing platform quirk, not corrected here). The admin UI both writes and reads only the GR row (ecommercen/settings/controllers/Adv_settings.php:58 and :119), while InitialSeed seeds one row per available_countries entry — so every non-GR row is frozen at its seed default and never reflects a merchant edit. A per-country map would publish stale seed data as merchant intent; a default_country lookup would serve that frozen value on a non-GR shop instead of the merchant's actual edit.
    • DECISION — 0 means no threshold configured, and the resolver fails closed to it. Registry::value() has no default parameter and returns null for a missing key, so the value is cast at the boundary exactly as LoyaltyConfigResolver does. No default threshold is ever invented: a fabricated number here would be a wrong customer-facing money claim, which is the whole defect being fixed. A client receiving 0 should show no free-shipping promise at all.
    • DECISION — raw value, no currency conversion. Legacy views wrapped trans_cost_limit() in applyCurrency(); the settled precedent for this contract is loyalty.rewardCashPerUnit — expose the raw number and state "in the shop currency" in the OpenAPI description.
    • NOT ADDED, recorded so its absence is not read as an oversight: a second key freeCodThreshold was considered and dropped. DELIVERY_COST_MIN_FREE exists only as a per-transporter, per-country transporters_options_pricing row — there is no ESHOP.DELIVERY_COST_MIN_FREE registry key anywhere — so there is no global display value to expose, and synthesizing one would have re-created the display-versus-charged confusion above.
    • Also corrected two stale comment references to trans_cost_limit(), which lives at ecommercen/helpers/cart_helper.php:98-102 (comment text only, no logic change), since the new resolver's docblock cross-references that exact block.
    • Tests: tests/Unit/Domains/StorefrontConfig/Shipping/ShippingConfigResolverTest.php (new — float cast, fail-closed 0.0, explicit-zero, single-key shape, and an argument assertion pinning the ('ESHOP', 'TRANS_COST_LIMIT', 'GR') read); StorefrontConfigProviderTest whole-payload contract assertion extended to the third section and kept strict; tests/Integration/Domains/StorefrontConfig/ContainerTest.php gains a registration guard (a dropped ->arg('$registry', null) fails at container compile) and a live contract assertion.
  • [4.121.0] feat(rest/product): server-side price bounds on product reads — filter[priceGte], filter[priceGt], filter[priceLte], filter[priceLt] (Advisable-com/ecommercen#642)

    • Why. filter[price] on GET /rest/product/product is an exact match, so a headless storefront could not express a price range — or even "priced above zero" — server-side. It had to fetch a page and filter client-side, which does not merely cost a round trip: a price facet computed over a page-limited result set on a 1,000-product category is simply wrong. The endpoint already allowed sort=price, so the column was orderable but not boundable.
    • The change. Four new filter keys, all targeting shop_product.price, in two inclusive/exclusive pairs: priceGte (price >= v), priceGt (price > v), priceLte (price &lt;= v), priceLt (price &lt; v). Any one may be sent alone; a lower and an upper bound together define a range. Five keys coexist on one column because the comparison type lives on the key, not on the column — which is also why the exact price key is untouched.
    • Why both an inclusive and an exclusive pair. The pairs differ only at the boundary value, which is exactly where the interesting query lives. filter[priceGt]=0 is the supported way to ask for only non-zero-priced products — the real use case, hiding the zero-priced gift SKUs from a recommendation strip. With only the inclusive pair a client would have to write priceGte=0.0001, encoding the column's decimal(11,4) scale into every consumer and breaking silently if that scale ever changed.
    • DECISION — filter[price] was NOT flipped to Between. That was the obvious-looking alternative and it is a trap twice over. It would have been a no-op: Product\Service never read $filter->type at all, building every Filter with the constructor's '=' default. And it is silently breaking: Filter's BETWEEN branch requires count($value) === 2, so a single-valued filter[price]=9.99 would emit no clause, no error and return the entire catalogue. filter[price] therefore keeps its exact semantics completely unchanged, pinned by its own regression test.
    • DECISION — NULL-priced rows are EXCLUDED once any bound is supplied. shop_product.price is genuinely NULLable (decimal(11,4) DEFAULT NULL, no migration alters it) and NULL >= x is NULL, never TRUE, so the row drops with no clause of its own — the effect legacy gets from price > 0. No OR price IS NULL is added. Asserted deliberately rather than inherited, so a future reader knows it was chosen.
    • DECISION — a non-numeric bound is dropped for THAT KEY ONLY. Guarded with is_numeric() in Product\Service; every other filter, including a bound on the other side, still applies and the response is still 200. There is no 4xx path for a malformed filter value anywhere on this platform — an unrecognised key is already dropped silently, and GenerateListRequest's throws are developer-error paths whose own comment notes that a client-facing throw "would 500". Dropping only the offending key also avoids the BETWEEN failure mode, which drops the whole predicate and widens the read.
    • NOT a visibility change, and specifically not a reversal of #613. No price predicate is added to any visibility scope or forced filter, ProductVisibilityScope is untouched, and zero-priced products remain fully readable by a storefront caller by default — they are predominantly gift SKUs a storefront must render. This is filter expressiveness; priceGt=0 is the client's per-request opt-in.
    • NOT legacy browse-gate parity — and priceGt=0 makes that easier to misread, so it is worth stating. Legacy's gate is active = 1 AND soft_delete = 0 AND price > 0 AND (stock > 0 OR negative_stock = 1). The price leg is now expressible; the stock leg is not, and this branch does not change that: shop_product has no stock column (stock lives on product_codes.stock, one-to-many), /rest/product/product exposes no stock filter, /rest/product/product-code's own filter[stock] is also Exact rather than a bound, and an OR spanning two tables cannot be expressed in a vocabulary that ANDs every declared key (see the comment at GenerateListRequest.php:258-263).
    • NOT the legacy price facet either. Legacy price-range browsing filters shop_prices_view.final_price — the post-discount price — not shop_product.price (ecommercen/eshop/controllers/Adv_product_categories.php:1157-1164, gated on registry OTHER/ENABLE_PRODUCT_PRICE_RANGES). Bounds over the raw price column are therefore a new capability, not a port: a client reproducing the legacy facet with these keys will disagree with legacy on every discounted product. A post-discount bound needs a different underlying column and is not in this change.
    • Shared platform surface, additive. FilterRequestType gains Gte/Gt/Lte/Lt; Filter::apply() gains matching '>='/'>'/'&lt;='/'&lt;' branches. All four pass the bound as where()'s second argument with the operator on the key, so CI3 escapes it and appends it as a separate literal — the safe shape FilterByDateChangedSince already uses. The unsafe BETWEEN branch (which concatenates its values into the condition string, where _compile_wh() passes an already-quoted literal through untouched) is deliberately left alone; it is tracked as #645. All 107 domain services that map filter types carry a default => '=' arm, so the new cases are inert for every domain that has not declared them.
    • Tests: tests/Unit/Domains/Support/Repository/Specification/FilterRangeOperatorTest.php (new — call-shape guard for all four operators, plus '=' regression), tests/Integration/Domains/Support/Repository/Specification/FilterRangeOperatorSqlTest.php (new — compiled-SQL escaping proof per operator against a real connection), tests/Integration/Domains/Product/Product/ServicePriceBoundFilterTest.php (new — the endpoint semantics: boundary pairs, NULL exclusion, non-numeric drop, filter[price] regression) and a #642 guard added to tests/Unit/Rest/Product/Controllers/ProductScopeTest.php (a client bound survives the storefront scope and the scope still forces exactly active + softDelete). FilterRequestType::Between had zero coverage before this, so these are the first tests in the area.
  • [4.121.0] test(domains/support): add a positive invariant requiring every relation onto a scope-bearing repository to declare a visibilityScope, closing the blind spot the existing allow-list inventory guard has always had (Advisable-com/ecommercen#641)

    • The new test. RelationConfigurationTest::test_every_relation_onto_a_scope_bearing_repository_declares_a_visibility_scope() is new: every relation targeting the Product repository must now declare a non-null visibilityScope, unless explicitly exempted with a stated reason. Today that's 17 relations onto Product — 10 scoped, 7 deliberately exempt (each backend-only, or in ProductCode.product's case a pinned, already-justified exception for cart/order VAT rendering). A second new test, test_the_invariant_reports_a_relation_whose_visibility_scope_went_missing(), proves the guard actually fails on a missing scope rather than assuming it — it feeds the detection routine a hand-built fixture reproducing the real hazard (a fork's stale Line.products copy) alongside relations that must not be flagged.
    • Why this is a second, complementary guard, not a duplicate. The existing test_only_the_reviewed_relations_declare_a_visibility_scope() is an allow-list: it collects only the relations that DO declare a scope and pins that exact sorted set. A relation that loses its scope contributes nothing to the collected set — the set is simply unchanged, and the allow-list test stays green. That's the gap this delivery closes: the allow-list catches an unreviewed addition, the new positive invariant catches a removal. Neither subsumes the other; both stay.
    • Why a fork sees this failure and upstream doesn't. The relation walk now also covers custom/Domains when that directory exists — it doesn't in the main repo, so this is inert here, but in a fork it puts the fork's own Custom\…\RepositoryConfigurator classes inside the invariant for the first time. Those are aliased over their upstream counterparts in custom/Domains/container.php, so they're the classes the DI container actually serves. If a fork's copy of a configurator is a pre-#637/#613 snapshot, it constructs fine, autowires fine, boots the container fine, and returns visibilityScope === null — exactly the shape #637, #639 and #640 each warned about in their own "Check for overrides" notes. This guard turning red on a fork is therefore not test noise: it means that fork is a real, live leak of hidden or unpublished rows to guests.
    • How to fix it. Re-sync the fork's copy of the flagged configurator against the current src/ version — pass the relevant scope class into the configurator's constructor, and hand relationScope() to the Relation's visibilityScope: argument as a named argument (Relation::__construct puts $visibilityScope tenth; a positional tenth argument lands silently in $pivotTable instead). Do not add the relation to visibilityScopeExemptions() to make the test pass — that list exists only for relations with no client-reachable path, each with its own stated justification, and a stale copy of a relation upstream scoped for a reason is not that. Exempting it re-opens the hole this guard exists to catch and silences the alarm along with it.
    • Known limitation, recorded in-test. The exemption list's six "backend-only owner" entries are snapshots of rest_policies.php at review time — the guard does not re-read that config, so an exemption goes stale if its owning endpoint is later opened to guests, the way #612 did for Line. The escalation path if that's ever suspected is deriving the requirement from rest_policies.php directly rather than trusting the hard-coded list.
    • No production code, no migration, no REST contract change, no new config key. Test-only.
  • [4.121.0] fix(rest/cms): scope Article.categories, Article.tags, BlogCategory.children/.parent (recursive) and BlogCategory.articles for storefront callers, closing the most severe depth-bypass #624 left open on the blog endpoints (Advisable-com/ecommercen#640)

    • Why. #624 scoped the cms/blog/article, cms/blog/category and cms/blog/tag endpoints themselves and denied ?sort=categories.active/?sort=tags.active/?sort=children.active, but — the same #637 lesson the #639 fragment restates — an endpoint's forced filter scopes only that endpoint, so the five relations above kept walking straight back into hidden rows. BlogCategory.articles was the severe member, and was fixed first: GET /rest/cms/blog/category?with=articles.translations is guest-readable and index() embeds articles on every row, so one unauthenticated request returned the blog_mui rows of every unpublished article — the complete pre-publication body of every draft, bulk-enumerable — because blog.is_published is NOT NULL DEFAULT 0. Article.categories/.tags and BlogCategory.children/.parent carried the matching hole for inactive taxonomy.
    • The fix, four new stateless scope classes. BlogCategoryVisibilityScope (blog_categories.is_active = 1) is declared on BlogCategory.children and BlogCategory.parent (both recursive — one declaration covers every depth including the ?with=children* form) and on Article.categories; BlogTagVisibilityScope (blog_tags.is_active = 1) is declared on Article.tags; ArticleVisibilityScope (blog.is_published = 1) is declared on BlogCategory.articles. A per-endpoint relations allow-list could not have closed any of these: RelationFilterMiddleware matches only the first ?with= hop, so a chain like ?with=categories.articles.translations or ?with=articles.categories is untouched by anything registered on the entry endpoint.
    • Two taxonomy scope classes, not one parameterised by table — deliberately, and this is the one asymmetry to read carefully before assuming the two behave alike. blog_categories.is_active and blog_tags.is_active are both int(2) DEFAULT NULL, so this delivery makes a real decision — forcing = 1 withholds a NULL-flagged row from storefront callers along with an explicitly-0 one — but the justification differs, and #624's own changelog entry already recorded the two tables separately for the same reason: tags are straight parity with legacy — Adv_blog_tags_model::getTagsFront() already selects is_active = 1, so an inactive or NULL-flagged tag was already invisible on the legacy storefront — while blog categories are a deliberate tightening: the legacy blog-category storefront never filters is_active at all (Adv_blog_category_model.php's getCategoriesFront() and every one of its five legacy call sites pass lang only), so inactive and NULL-flagged categories are reachable on the legacy storefront today and stop being served over REST here. NULL is treated as not-visible on both, which matches legacy wherever it tests the flag at all (is_active = 1, never != 0). One shared class would have collapsed two independently-ratified justifications into a single hedged docblock; each class states its own instead.
    • Own-flag, not an ancestor chain — measured at zero for this tree. BlogCategoryVisibilityScope tests each row's own flag rather than every ancestor's. Across 14 tenant databases there are zero active-but-unreachable blog_categories rows: the tree is essentially flat (max depth 2 in only 2 of 14 tenants, no nesting at all in the other 12), and the single inactive parent that has children has zero active children, so both rules agree even there.
    • No sort denial added on BlogCategory.articles, and the audit is recorded rather than assumed. BlogCategory publishes four articles relation sorts (id, date, hits, authorId) and none of them exposes a visibility flag — all four go inert as an ordering oracle the moment the relation is scoped, since a row that is no longer returned cannot be ordered by. Denying an already-inert sort would be over-denial, the same call #624 made on cms/document and cms/blog/tag.
    • ArticleVisibilityScope lives at the Article domain root rather than nested under Blog\Category, and is registered public and autowired specifically so #625's Product\Category configurator can resolve it cross-module — it is the article scope #625 has been blocked on. Do not move or narrow its visibility without checking #625 first.
    • Tests: CommentVisibilityScopeTest updated for ArticleConfigurator's new constructor parameters; RelationConfigurationTest's declared-visibility-scope inventory extended to cover all five relations here.
  • [4.121.0] fix(rest/cms): scope Page.parent and Page.children (recursive) for storefront callers, closing the depth-bypass #624 left open on GET /rest/cms/page (Advisable-com/ecommercen#639)

    • Why. #624 scoped the cms/page endpoint itself — forcing filter[isPublished]=1, gating show() — and denied ?sort=children.isPublished, but recorded both fixes as endpoint-local: an endpoint's forced filter scopes the endpoint it is registered on and nothing else (the #637 lesson), so ?with=children and ?with=children.translations kept walking straight back into unpublished rows. categories.is_published is NOT NULL DEFAULT 0, so every unpublished page ever created was reachable through those two hops, and because PageMuiResource emits categories_mui.fulltext with no isBackend() gate, ?with=children.translations returned the complete pre-publication BODY of every unpublished page — not merely its existence — bulk-enumerable in one guest request since index() embeds children on every row. Page.parent carried the identical hole in the other direction: an unpublished ancestor readable from a published child.
    • The fix. A new PageVisibilityScope (categories.is_published = 1) is declared on both Page.parent and Page.children in Cms\Page\Repository\RepositoryConfigurator. One declaration per relation covers every nesting depth automatically, including the recursive ?with=children* form — the relation-name match strips the * marker, so the walk cost is no longer attacker-chosen. A per-endpoint relations allow-list could not have closed this: RelationFilterMiddleware matches only the first ?with= hop.
    • Own-flag, not an ancestor chain — and measured, not assumed. The scope tests each row's own is_published flag, not whether every ancestor up the tree is also published, so a visible child under a hidden parent would remain visible in principle. Across 14 tenant databases there are zero unpublished categories rows that have children — a real measurement, not a vacuous one, since the page tree is genuinely nested in this data (two tenants reach depth 3, one with 119 of 125 rows nested). If a visible-child-under-hidden-parent row ever appears, the ancestor-chain treatment Product\Category\CategoryVisibilityScope uses is what this would have to grow into.
    • Accepted parity delta, not a straight port. Legacy is internally inconsistent here: Adv_categories_model::get_content($slug) gates is_published = 1, so legacy never renders an unpublished page BODY on any path, while get_childs() does not gate it and is live storefront code for both sibling and child navigation. So REST returning unpublished page bodies through children.translations was more permissive than every legacy path, not at parity with one — closing it is a correction, not a new restriction relative to some legacy baseline. The genuine cost is narrower: REST now also stops listing unpublished nodes in a page tree where legacy's get_childs() still lists them (just without their bodies). A client using those nodes for an editorial-preview navigation must move that to a backend token.
    • No sort denial needed here beyond what #624 already shipped — ?sort=children.isPublished was already denied for storefront callers; this delivery closes the row set that denial's oracle was standing in for.
    • Tests: tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php's declared-visibility-scope inventory is extended to cover Page.parent/Page.children.
  • [4.121.0] fix(rest/product): declare ProductVisibilityScope on nine relations that walk back into the catalogue — eight guest-reachable, one (Wishlist.product) customer-reachable — closing the depth-bypass #613 left open on every guest- and customer-reachable path (Advisable-com/ecommercen#637)

    • Why. #613 gave Product row-visibility scoping — active = 1, soft_delete = 0 — but a Relation::$visibilityScope does not cascade across relation definitions, so its Line.products declaration protected exactly one path back into the catalogue. #613's own changelog entry named the gap and enumerated it under #618: several other relations still walked straight into the unscoped catalogue. Nine of those returned inactive and soft-deleted products to a caller with no legitimate reason to see them — eight to a completely unauthenticated guest, one (Wishlist.product) to any authenticated customer — the very rows #613 had just removed from GET /rest/product/product itself, still reachable one relation hop away. This declares the same scope on all nine.
    • Not a claim that the Product relation surface is now complete. Download.product, ProductList\ProductLp.product, ProductMeta.product, Related.product and Related.relatedProduct, WaitingList.product, and ProductCode.product remain unscoped and stay tracked under #618. Six of the seven are genuinely backend-only — no storefront exposure. WaitingList additionally declares 'store' => ['auth' => 'customer'] in rest_policies.php, but that's a write serving no ?with= embed, so the read conclusion is unchanged. ProductList\ProductLp.product was re-derived graph-wise, not by policy inheritance: no configurator anywhere declares a relation onto ProductList\ProductLp\Repository\Repository, so it has no inbound relation edges and its only exposure is its own backend endpoint — reachability is a property of the relation graph, not of the owning endpoint's policy, which is the exact error this issue exists to correct. The seventh, ProductCode.product, is not backend-only and is the one member of this list a storefront caller can actually reach: guest and unconditional, through Cart::CART_RELATIONS's hard-coded items.productCode.product walk on every cart render (Cart::class defaults 'auth' => 'guest') with no ?with= involved; guest depth-2 through the productCodes relation Product\Repository\RepositoryConfigurator declares (Product index/show/item are guest with no relations allow-list); and customer depth-3 through Order's 'auth' => 'any' default onto basket.productCode.product. It is nonetheless correctly left unscoped, deliberately rather than by oversight: CART_RELATIONS nests vat under product, so scoping it would null the exact object CartTotalsCalculator reads for VAT resolution (product.vat, the #563 PRICING_RELATIONS superset) and blank checkout line rendering. Cart and order lines are transaction records that must keep rendering a product the caller already transacted on regardless of its current catalogue visibility — the opposite of a wishlist, a pre-purchase catalogue bookmark, which does take the scope. This delivery closes the guest- and customer-reachable paths that should close, not the whole surface.
    • The nine relations closed, each in a RepositoryConfigurator.php under src/Domains/:
      • Cms\Blog\Article\Repository → products
      • Event\Event\Repository → products
      • Cms\Video\Repository → products
      • Product\Promo\Repository → products
      • Product\Variation\Value\Repository → products
      • Product\Variation\Repository → product
      • Product\Review\Repository → product
      • Product\PriceTracking\Repository → product
      • Product\Wishlist\Repository → product
    • Nine direct endpoints. Eight are guest-readable on index/show/item: /rest/cms/blog/article?with=products, /rest/event/event?with=products, /rest/cms/video?with=products, /rest/product/promo?with=products, /rest/product/variation?with=product, /rest/product/variation-value?with=products, /rest/product/review?with=product, /rest/product/price-tracking?with=product. The ninth, /rest/product/wishlist?with=product, is reachable only by an authenticated customer — index/show are 'auth' => 'any' in rest_policies.php — with a narrower blast radius: the Wishlist controller already restricts non-backend callers to their own rows (#418), so a customer could see a hidden product only on their own wishlist.
    • Nested paths — the whole reason the rule lives on the relation definition rather than per endpoint. #613 already scoped Product itself, so the same fix closes /rest/product/product?with=events.products, ?with=videos.products, ?with=variationValues.products, ?with=articles.products, /rest/product/vendor?with=videos.products, and the depth-3 /rest/product/product?with=vendor.videos.products. None of those could have been closed by a per-endpoint relations allow-list: RelationFilterMiddleware matches only the first ?with= hop, which on every path above is events, videos, variationValues, articles or vendor — never products.
    • Surfacing shape follows the relation type, unchanged from how a missing product already renders: on the five MANY_TO_MANY products collections a hidden product simply drops out of the array (shorter list, possibly empty, no key removed); on the four BELONGS_TO product embeds (Variation.product, Review.product, PriceTracking.product, Wishlist.product) a hidden product serializes as null — the same shape a row with no product_id already produces. The owning row itself is not filtered: a review of a now-hidden product is still returned, with product: null.
    • Backend callers are entirely unaffected, on every one of the nine, at every nesting depth — the scope is suppressed centrally by the single Relation::VISIBILITY_EXEMPT_ALL grant HandlesRestfulActions applies whenever the request ResourceContext is backend. That central grant is exactly why visibilityScope is the correct slot here rather than the unsuppressible scope: an admin moderation, restock or trash-recovery screen embedding a product through any of these nine relations must keep seeing inactive and soft-deleted rows, and folding the rule into scope would have blinded those screens with no way to opt out.
    • price > 0 is deliberately NOT scoped, unchanged from the #613 decision. Zero-priced products are predominantly gift SKUs: measured across real tenants, 619 of pharm16's 1,070 zero-priced rows sit in a gift pool (88% of that tenant's 703-product pool), 1,241 of discount's 2,496, 494 of demo's 521. "We don't sell zero-priced products unless they're gifts, shown as gift options" is a checkout constraint enforced by the gift engine, so hiding the rows here would break the exception rather than implement the rule — a headless storefront still needs them to render a gift option's name and image. active = 1 already removes zero-priced debris; shop_product.price is NULLable, so a price clause would additionally drop every NULL-priced row. This was owner-ratified on #613 and carries a regression test (test_a_zero_priced_active_product_is_still_returned()) asserting a zero-priced active product is still served through all nine relations, for a non-backend caller (guest or authenticated customer).
    • Not done here, deliberately: row-level scoping of the owning entities. A review, price-tracking row, variation value or wishlist entry that belongs to a hidden product is still listed in full — only the embedded product/products relation is scoped. Tracked separately under #624, #625, #626 and #627.
    • Tests: new tests/Unit/Domains/Product/Product/ProductRelationVisibilityScopeTest.php (9 test methods; 8 run against all nine relations via a scopedRelations() data provider — 72 cases — covering the declaration, the exact staged clauses, named-argument placement, guest vs. backend row sets, a named-relation exemption, the gift-SKU guard, and a no-price-clause guard; the ninth runs the six nested ?with= paths above through a real loader at depth). tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php's test_only_the_reviewed_relations_declare_a_visibility_scope() inventory guard is extended from the five entries #588/#613/#616 left it at to the full fourteen, tagging each with its originating issue so the interleaved sort order stays traceable. tests/Unit/Domains/Cms/Blog/Comment/CommentVisibilityScopeTest.php is updated for ArticleConfigurator's new constructor parameter.
  • [4.121.0] fix(rest/product): emit isSensitive to storefront callers on ProductCategoryResource (Advisable-com/ecommercen#636)

    • Why. CategoryResource withheld isSensitive from guest and customer callers behind the isBackend() gate, while legacy treats the underlying flag as load-bearing across three independent consumption channels, not one chain: (1) analytics suppression — the category page publishes it as isSensitiveCategory (Adv_product_categories.php:385), consumed only by application/views/production/google_header.php:72 and google_footer.php:47 to gate remarketing/analytics tags on sensitive categories; (2) menu rendering — a separate key, is_sensitive, is selected and carried by the menu-tree builder (Adv_product_category_model.php:1308, :1331, :1384) and read by both storefront themes (application/views/default/components/library/header/main_menu.php:34, :50 and application/views/main/components/header/main_menu.php:435, :451); and (3) row exclusion — the child-listing query excludes sensitive rows server-side (Adv_product_category_model.php:957), a row-scoping question this fix deliberately does not address (see Notes below). REST was the outlier: it declared isSensitive as an allowed filter and sort while withholding the value itself, and a headless storefront built against REST alone had no way to reproduce legacy's sensitive-category presentation. The issue itself presented a tempting but wrong alternative reading — the shape (hidden yet filterable/sortable) matches the #618 family exactly, so a developer working #625 could reasonably reach for withDeniedFilter() here and harden the regression instead of fixing it. Verified against legacy and rejected: three independent, live consumption channels make the flag load-bearing, not vestigial.
    • The change. isSensitive moves out of the isBackend() block into the always-emitted base payload in Category/Resource.php::resource(), cast (bool) as before. A comment at the emission site records the two theme paths so a future reader working the #618 family does not "re-close" it. ProductCategoryResource's OA\Schema already declared isSensitive unconditionally (only the runtime gate withheld it), so no OpenAPI annotation change was needed.
    • NO withDeniedFilter() / withDeniedSort() is added for isSensitive, and that is deliberate. The #618 family denies a filter or sort over a column a Resource withholds, because an allowed filter/sort over a hidden column is a content oracle — the response leaks the value indirectly even though the field itself is invisible. Once isSensitive is legitimately part of the public payload there is no withheld-column left to oracle over: filtering or sorting on a field the caller can already read in the same response is ordinary API surface, not a leak. It remains filterable (src/Domains/Product/Category/ListRequest.php:24) and sortable (:99), which was already true and is now simply consistent with the field being visible. A future reader must not read this endpoint as unfinished and add a denial here.
    • The four sibling fields do NOT share one answer — each was checked against legacy individually, because the issue explicitly warned against assuming they match isSensitive just because they sit in the same isBackend() block:
      • order — legacy carries it as a named key in the same menu-tree payload, so it is a parity gap too, but no storefront template dereferences it (it is the menu's ordering contract, not rendered data), and it is already both filterable and sortable so a client can order by it without reading it. Emitting it is a defensible separate change; bundling it here would widen the payload diff with no demonstrated consumer. Not emitted.
      • sliderId, menuSliderId, extraSliderId — genuine withheld columns, not regressions. Legacy resolves each one server-side and publishes only the resolved slider object, never the raw id (Adv_product_categories.php:668-678, :447-480, and Adv_product_category_model.php:1631-1651 — getTopLevelSliders() — for menuSliderId); menuSliderId is not even selected into the menu tree. REST withholding the id is already correct. Stay backend-only, unchanged.
    • Tests: tests/Unit/Rest/Support/Resources/ScopeFilteringTest.php — isSensitive moves from CATEGORY_BACKEND_ONLY to CATEGORY_PUBLIC, pinning the exact split. tests/Unit/Rest/Product/Resources/Category/ResourceTest.php gains storefront-context coverage that did not exist before (every prior test in that file ran in backend context only, which is why nothing caught the regression): two new tests assert isSensitive is present and correctly cast for both SCOPE_PUBLIC and SCOPE_CUSTOMER, and that order, sliderId, menuSliderId and extraSliderId stay absent in both.
  • [4.121.0] feat(transporters): support BoxNow mass voucher printing (Advisable-com/ecommercen#632)

    • The gap. BoxNow had single-voucher printing but not batch printing. $config['transportersSupportingBatchVoucherPrint'] in application/config/app.php did not list BOXNOW, and the admin order-list "Μαζική εκτύπωση vouchers BoxNow" bulk action renders only for transporters on that list — so operators printed BoxNow shipping labels one order at a time regardless of how many orders they selected.
    • What changes. BOXNOW is added to transportersSupportingBatchVoucherPrint. Selecting orders in the admin order list and running the BoxNow mass-print action now returns a single PDF containing one shipping label per selected order that has a voucher (gtcode). AdvPrintVoucher::printBatch() gains a BOXNOW case delegating to new printBatchBoxNowVoucher(), which collects the selected orders' gtcodes and calls new Transporters\BoxNow\BoxNow::getLabels(array $parcelIds): ?string. getLabels() calls the carrier's POST /labels:search endpoint, which returns the multi-label PDF directly — no client-side PDF merging.
    • Two new BoxNow settings control the sheet layout, both read by getLabels() from BoxNowConfig rather than passed in by the caller, since layout is a per-transporter setting, not a caller concern:
      • Paper size — A4 (default) or A6 — reg_key PAPER_SIZE.
      • Labels per page — 1, 2, or 4 (default 4) — reg_key LABELS_PER_PAGE. Both are declared in BoxNowConfig::getFieldConfiguration(), read in BoxNowConfig::initialize(), backed by new BoxNowHelper::DEFAULT_PAPER_SIZE / DEFAULT_LABELS_PER_PAGE constants and paperSizes() / labelsPerPage() option lists, and exposed as two new dropdowns on application/views/admin/transporters/settings/boxNowSettings.php. The view reaches the option lists through two new transporters_helper functions rather than calling BoxNowHelper statically, matching the getTransporterDeliveryOptionTypes() / getParcelSizeDropDown() dropdowns already in that file. The same fields feed the SaaS provisioning wizard, which reads the transporter's field configuration the same way.
    • Locale. New key eshop.admin.transporters.labelsPerPage in all 8 locale files. The paper-size label reuses the existing eshop.admin.transporters.paperSize key rather than adding a duplicate.
    • Upgrade-safe default. An install that has never opened the BoxNow settings screen still prints correctly, so no admin visit is required after upgrade. Two mechanisms are needed because two different states have to be covered. BoxNowConfig::$paperSize / $labelsPerPage are declared with the BoxNowHelper defaults (A4 / 4), which covers a transporter with no stored settings at all — __construct() calls initialize() only when settings exist, so nothing inside it would run. And initialize() falls back with ?: on top of that, which covers a row that is stored but empty: BaseTransporterConfig::filterObject()'s default argument fires only when the row is absent, never when its reg_value is '', and saveBOXNOWSettings() writes '' for any key missing from the POST — as happens on a fork whose own copy of the settings view lacks the two new dropdowns.
    • Values are validated on write. validateSettingsBOXNOW() applies in_list[A4,A6] and in_list[1,2,4] alongside trim, so an out-of-list value cannot be stored through the admin form and reach the carrier as paperSize: '' or perPage: 0. The config passes stored values through unmodified on read. Both dropdowns print form_error() like the eight fields above them: these two rules are the only ones in this form that can fail, and saveBOXNOWSettings() runs only when form_validation->run() passes, so without the error output a rejected value would discard the whole submit — client id and secret included — with no signal on the re-rendered page.
    • BoxNow requests can now be given a timeout. BoxNow::buildOptions() has always read $this->config->timeout and applied it as connect_timeout and timeout when greater than zero, but no config declared that property, so BaseTransporterConfig::__get() returned null and the branch was unreachable — every BoxNow request ran with Guzzle's unbounded default. BoxNowConfig::$timeout now declares it, which makes the guard live. It defaults to 0, so behaviour is unchanged out of the box and no existing call gains a timeout it did not have. Raising that one value is the whole seam: BoxNow sets no per-request timeout of its own, so the value bounds every call including the batch /labels:search one, whose duration is the only one that scales with the size of the selection. The property is a code-level default only — not read in initialize(), no reg_key, no getFieldConfiguration() entry — so it appears in neither the admin form nor the SaaS wizard.
    • Deliberately one request, no chunking. The whole selection's gtcodes are sent to /labels:search in a single call. BoxNow's API documentation is silent on any maximum parcel count for that endpoint, but a live deployment has printed 200 orders in a single call successfully, so the endpoint comfortably handles realistic batch sizes. Unlike ACS (chunks of 10) and Center (chunks of 20), no chunking is applied here: 200 is a tested floor rather than a known ceiling, and adding a PDF-merge step before the real limit is known would be speculative — the existing mergePdfs() helper hardcodes ACS label geometry and is unusable for A4 anyway.
    • Unchanged. Single-voucher BoxNow printing — AdvPrintVoucher::printBoxNowVoucher() — is untouched.
  • [4.121.0] fix(admin/orders): resolve the smart-point validation label to its defined language key (Advisable-com/ecommercen#631)

    • The typo. ecommercen/eshop/controllers/Adv_orders_admin.php:690 registered the smart-point validation rule with the singular key eshop.admin.order.select.smart.point — set_rules('smartPointJsonData', t('eshop.admin.order.select.smart.point'), 'trim|required|callback_smartPointJsonDataCheck'). The key is defined only in the plural, eshop.admin.order.select.smart.points, in all 8 ecommercen/language/&lt;lang>/adv_advisable_lang.php files (chinese/english/french/german/greek/russian/spanish at line 449, italian at 450), so the lookup always missed. An admin hitting this validation saw the raw dotted key rendered as the field label instead of the translated string (e.g. english 'Select a smart point', greek 'Επιλέξτε ένα smart point'), and system/core/Lang.php logged Could not find the language line "eshop.admin.order.select.smart.point" once per occurrence. Admin-facing and cosmetic — no data, money, or contract impact. Observed live on a 4.120 client the first time an admin hit that validation.
    • The fix. One character: the call site now asks for the plural key, t('eshop.admin.order.select.smart.points'). That is the entire diff — one line, one file. No language file was touched.
    • Why the call site moved rather than the language files. The plural key's value is already singular prose in every language (e.g. 'Select a smart point', not 'Select smart points') — only the key name is pluralised — so the call site reads as the typo, not the translations. Adding a ninth, singular key to all 8 files would leave two keys with identical meaning, which is the worse outcome.
    • Reachability. The rule is registered only when sameaddress is ORDER_ADDRESS_BILLING/ORDER_ADDRESS_SHIPPING and smartPointJsonData is non-empty (Adv_orders_admin.php:686-691) — i.e. on a real admin smart-point selection, on the order add / edit / repeat flows.
    • Tests: none added. There is no PHPUnit harness covering this controller's validation-rule label registration — tests/Legacy/Eshop/AdvOrdersAdminHooksTest.php covers hooks, not label resolution — and standing one up for a one-character key correction is disproportionate.
  • [4.121.0] fix(captcha): register a proper captchaCheck validation message and field label (Advisable-com/ecommercen#630)

    • The leak, not a blank message. GoogleRecaptchaTrait::setCaptchaValidationRule() registered set_rules('g-recaptcha-response', '', 'trim|required|callback_captchaCheck') — an empty field label and no set_message() call anywhere in the trait, and no form_validation_captchaCheck language line anywhere in the tree. Form_validation::set_rules() defaults an empty label to the field name (system/libraries/Form_validation.php:159-160), so {field} resolved to the raw slug g-recaptcha-response, not a blank. CI_Form_validation::_get_error_message() (system/libraries/Form_validation.php:658-672) falls through per-field errors → set_message() → lang->line('form_validation_captchaCheck') → lang->line('captchaCheck', false) → and finally returns lang->line('form_validation_error_message_not_set') . '(captchaCheck)'. That last key is defined, in all 8 application/language/*/form_validation_lang.php:32, so a visitor who failed the captcha saw a developer-facing diagnostic naming that raw field slug — in Greek, Δεν είναι δυνατή η πρόσβαση σε ένα μήνυμα σφάλματος που αντιστοιχεί στο πεδίο g-recaptcha-response.(captchaCheck). The original report described this as an empty message; it was actually a leaked internal string.
    • A second symptom from the same empty label. The empty label also degraded the required message on the same rule: a missing g-recaptcha-response rendered form_validation_required (e.g. application/language/greek/form_validation_lang.php:4, 'Το πεδίο {field} είναι υποχρεωτικό.') naming the raw field slug g-recaptcha-response — an internal input name shown to a visitor — instead of a human-readable label.
    • Log noise. system/core/Lang.php:119-129 logged Could not find the language line "form_validation_captchaCheck" once per failed captcha attempt.
    • The fix. ecommercen/core/GoogleRecaptchaTrait.php now calls $this->form_validation->set_message('captchaCheck', t('captcha.validation.error')) immediately before set_rules(), and passes t('captcha.field.label') as the field label instead of ''. The set_message() call sits next to set_rules() in the same method deliberately, so the message travels with the rule and a future edit can't drop one without the other. Two new keys — captcha.validation.error and captcha.field.label — were added to ecommercen/language/&lt;lang>/adv_theme_lang.php in all 8 language directories (chinese, english, french, german, greek, italian, russian, spanish), next to the existing waiting_list.error_captcha.
    • Why set_message() over a language line. Mirrors what the sibling ecommercen/core/CodeigniterCaptchaTrait.php already does for its own captcha rule, but sources the string from t() instead of a hardcoded literal. Keeping the message next to the rule (rather than adding the missing form_validation_captchaCheck line) means it can't be lost again by a language file nobody updated.
    • Affected surfaces. Three call sites use this trait: ecommercen/forms/controllers/Adv_forms.php (contact form and return form) and ecommercen/eshop/controllers/Adv_waiting_list.php (waiting list). The waiting list was not user-facing broken — it treats form_error('g-recaptcha-response') as a boolean and substitutes its own t('waiting_list.error_captcha') — so its only prior symptom was the log noise. The contact and return forms are the user-facing half of this fix.
    • Tests: none added. tests/Unit/Core/GoogleRecaptchaTraitTest.php and its FakeFormValidation support fake are being introduced by the in-flight feature/586-recaptcha-v3-hybrid (#586), and neither path exists on develop yet — a parallel harness here would be a guaranteed add/add conflict, so coverage is deliberately left to that branch's merge.
  • [4.121.0] fix(rest/slider): deny filter[audienceId] for storefront callers on GET /rest/slider/slide (Advisable-com/ecommercen#628)

    • The question. #628 asked whether filter[audienceId] remained informative to a storefront (guest/customer) caller on GET /rest/slider/slide / /item after #615 shipped SlideVisibilityFilter. It does — #615 does not neutralise it, because the two controls act at different layers.
    • The leak. filter[audienceId] is folded into the SQL WHERE and into the COUNT(*) at Domains\Slider\Slide\Service::match()/count() (src/Domains/Slider/Slide/Service.php:56-58), while SlideVisibilityFilter is a POST-FETCH PHP pass over already-counted rows (src/Rest/Slider/Controllers/Slide.php:208-230). That splits into two channels: (1) ?filter[id]=X&filter[audienceId]=N makes pagination.total a 1-bit oracle over the true audience id for every slide, including ones the caller can never see, and FilterRequest::values() IN-lists a comma-separated value so a caller can binary-search rather than enumerate; (2) for a slide targeted at an audience but not membership-restricted, isAudienceVisible() still returns true, so empty-vs-nonempty on the row set reconstructs the exact audienceId value SlideResource withholds from every non-backend caller.
    • The fix. Slide::enforceStorefrontSlideScope() — a protected method following the BlogComment::enforceStorefrontCommentScope() override-seam convention — early-returns on ResourceContext::isBackend() and otherwise calls withDeniedFilter('audienceId'). It runs immediately before buildListRequest() in both index() and item(), because buildListRequest() is what applies deniedFilterKeys and the constructor is too early (resourceContext is not yet set). Backend callers are entirely unaffected; the key stays declared in Domains\Slider\Slide\ListRequest::setAllowedFilters().
    • What was explicitly NOT changed, and why. No withDeniedSort('audienceId') — it is not a declared sort (setAllowedSorts() publishes only id, priority, title.{locale}), and denying an undeclared key would fail tests/Unit/Rest/Support/Controllers/DeniedKeysAreDeclaredTest.php for no security benefit. No change for dateStart/dateEnd — neither is a declared filter or sort on this ListRequest, so there is nothing to deny; a documented no-op. show() is not touched — it fetches by primary key outside the filter pipeline and already gates per row via isSlideVisible(). The declarative ScopesStorefrontRows trait was deliberately not used — it is built around a forced row flag, and there is no server-chosen audienceId value to force here; StorefrontRowScopeWiringTest forbids denying a key that is also force-filtered.
    • What remains open, by design. The residual pagination.total EXISTENCE oracle — ?filter[id]=X alone still returns total: 1 for a slide the caller cannot see — is a separate, wider-blast-radius concern (recomputing pagination post-visibility touches a shared contract every list endpoint relies on) and is intentionally not addressed here.
    • Tests: added to the existing tests/Unit/Rest/Slider/Controllers/SlideScopeTest.php suite (now 26 tests) — guest/customer/backend coverage of index() and item() proving the filter is dropped for storefront callers and kept for backend, plus regression coverage that requested sort is unaffected for every context. Authentication alone is exercised as non-privileging (a customer context is asserted separately from guest), echoing #615's original defect shape.
  • [4.121.0] fix(rest/product): scope GET /rest/product/category and its four relations for storefront callers — unpublished categories and draft blog articles are no longer guest-readable (Advisable-com/ecommercen#625)

    • Why — the three-fact defect. ProductCategoryResource hid the flag (published was simply omitted from the projection), nothing removed the rows (every category, published or not, was returned), and the flag stayed client-filterable (Category\ListRequest declared published a freely settable allowed filter). Combined, ?filter[published]=0 asked for exactly the rows the projection was withholding, on a route that is auth => guest on index/show/item.
    • Endpoint half. filter[published] is now server-forced to 1 for storefront callers on index and item, so a supplied value is ignored rather than ANDed. show() gets its own per-row 404 for a hidden category — required, not belt-and-braces, because show() fetches by primary key outside the filter pipeline, so a forced filter provably cannot reach it.
    • Relation half. children, parent — including the recursive ?with=children* / ?with=parent* forms — and relativeCategories now carry the #588 ancestor-chain visibility rule via CategoryVisibilityScope (reused, not reinvented). articles carries blog.is_published = 1 via ArticleVisibilityScope, reused cross-module from #640, so the product-category → article edge and BlogCategory.articles express one definition of a visible article rather than two that drift.
    • ?with=articles.translations was the severe path. blog.is_published is NOT NULL DEFAULT 0 and BlogArticleMuiResource emits description with no isBackend() gate, so a single unauthenticated request returned the full pre-publication body of every draft article linked to any product category — bulk-enumerable in one call, since index() embeds the relation on every row it returns.
    • The endpoint rule is legacy parity, not a tightening. Every storefront read in Adv_product_category_model already constrains the flag: the root menu (:265), the anchored child tree (:374, :377, :394), the child listing (:957), the vendor and sitemap trees (:1436, :1444) and the product-join listing (:1905, :1936) all gate published = 1. REST was the outlier.
    • ?with=tagGroups is deliberately left unscoped — Product\Tag\Category\Repository (shop_product_group_tags) carries no visibility flag at all, so there is no rule to express; attaching the category scope there would be a hard SQL error, not a silent mis-filter, since the closure names shop_product_category.id, a table absent from that relation's FROM.
    • No sort denial accompanies either half: ?sort=published goes constant under the forced filter and ?sort=children.published goes inert the moment children is scoped in this same change, so both would be over-denial rather than a fix — the same call #624 made correctly on cms/document/cms/blog/tag. Both sort keys stay declared for backend callers.
    • Backend callers are entirely unaffected, on the endpoint and on all four relations, via the central Relation::VISIBILITY_EXEMPT_ALL grant HandlesRestfulActions applies whenever the request ResourceContext is backend — the admin catalogue tree, where an unpublished branch gets published, keeps seeing every row and may still filter/sort by published freely.
    • Tests: tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php's declared-scope inventory gains the four Product\Category\Repository\RepositoryConfigurator entries (articles, children, parent, relativeCategories).
  • [4.121.0] fix(rest/cms): scope five guest-readable CMS endpoints for storefront callers — hidden pages, blog articles, documents, blog categories and blog tags are no longer guest-readable (Advisable-com/ecommercen#624)

    • Why. Five guest-readable endpoints shared the identical three-fact defect: the Resource hid the visibility flag, nothing removed the rows, and the flag stayed client-filterable — so an unauthenticated caller could ask for exactly the hidden rows.

      EndpointForced key → column
      /rest/cms/pageisPublished → categories.is_published
      /rest/cms/blog/articlepublished → blog.is_published
      /rest/cms/documentactive → documents.active
      /rest/cms/blog/categoryactive → blog_categories.is_active
      /rest/cms/blog/tagactive → blog_tags.is_active

      One unauthenticated request each — ?filter[isPublished]=0&with=translations, ?filter[published]=0&with=translations, ?filter[active]=0&with=translations — harvested the withheld set in bulk.

    • The exposure was the full unpublished record, not merely row existence. The Mui resources emit the body with no isBackend() gate: Page/MuiResource.php:40 serializes content from categories_mui.fulltext, Blog/Article/MuiResource.php:36 serializes content from blog_mui.description, and Document/MuiResource.php:29 serializes a working /files/documents/ URL for the attached PDF. And categories.is_published / blog.is_published are both NOT NULL DEFAULT 0, so every draft ever created was guest-visible by default — this was never a narrow edge case.

    • The fix, three changes per endpoint. index()/item() server-force the visible value for that endpoint's key via withMandatoryFilter(), so a supplied value is ignored rather than ANDed — the repository ANDs same-column specs, so combining would return an empty set and disguise the fix as an empty list. show() gates per row and returns 404 for a hidden row, because it fetches by primary key outside the filter pipeline, where the forced filter can never reach. The whole scope is skipped for backend callers via isBackend(), on every action, so an admin listing stays entirely unfiltered and show() keeps returning hidden rows.

    • Three sort denials, all storefront-only, closing live ordering oracles: withDeniedSort('isPublished') on cms/page (closing ?sort=children.isPublished over the still-unscoped children self-relation — an interim measure, tracked under #639, until that relation itself is scoped), withDeniedSort('active') on cms/blog/article (closing both ?sort=categories.active and ?sort=tags.active — Article.categories/.tags are unscoped relations, and scoping the category/tag endpoints does nothing for them, the #637 lesson), and withDeniedSort('active') on cms/blog/category (closing ?sort=children.active). cms/document and cms/blog/tag get no denial: their own ?sort=active goes constant under the forced filter (every row a storefront can receive already has the flag set) and neither declares a relation sort over a withheld column, so a denial there would be over-denial rather than a fix.

    • The denied keys are the bare field names, not dotted paths — worth spelling out because it looks like a typo otherwise. GenerateListRequest::parseRelationSort() matches a denial on the field segment, at the root and at every relation depth, so one bare active (or isPublished) closes both the root sort and every relation-sort form at once; a dotted children.active would silently no-op and fail DeniedKeysAreDeclaredTest, which resolves a denied key against declared key names. The accepted side effect: the inert root ?sort=isPublished / ?sort=active is dropped for storefront callers too, since both spellings resolve to the one field — harmless, because under the forced filter every row a storefront can receive already has that flag set, so the ordering is by a constant.

    • The legacy logged-in-customer draft preview is gone, deliberately, not by parity. ecommercen/blog/controllers/Adv_blog.php:230-233 served an unpublished article to any logged-in customer. REST does not reproduce that: isBackend() is the only gate on all five endpoints, so a customer token now gets exactly the 404 a guest gets. Preserving the legacy affordance would have reproduced the #615 defect shape, where isAuthenticated() is satisfied by any customer token on a guest-policy route — making "customer preview" mean "every customer reads every draft". A client storefront using "log in and open the article URL" as a draft-preview link will start seeing 404s and must move to an admin-token call.

    • NULL-flagged taxonomy rows are now withheld — parity for tags, a deliberate tightening for categories. blog_categories.is_active and blog_tags.is_active are int(2) DEFAULT NULL — genuinely nullable, unlike the other three flags in this batch — and forcing = 1 withholds a NULL-flagged row from storefront callers. For blog_tags this matches legacy: its storefront listing selects is_active = 1 (Adv_blog_tags_model::getTagsFront(), ecommercen/blog/models/Adv_blog_tags_model.php:24-27), so a NULL-flagged tag was already invisible there — not a parity regression. For blog_categories it is not parity — legacy never filters on is_active at all: Adv_blog_category_model.php's only two references to the column (:202, :236) emit it into the returned array rather than filtering by it, getCategoryBySlug()/getCategoryData() filter on slug and lang only, and every getCategoriesFront() call site in ecommercen/blog/controllers/Adv_blog.php (:135, :306, :366, :579, :1009) passes only lang. Legacy's blog-category storefront listing and detail page show inactive and NULL-flagged categories today, so /rest/cms/blog/category forcing active = 1 is a deliberate tightening beyond legacy — the same class of divergence as the dropped customer draft preview above — not a restatement of existing behaviour. It is still the right behaviour, since the flag exists to hide rows and legacy simply never enforced it on the storefront; the size of the affected NULL population is unmeasured for both tables, so treat it as unknown rather than assumed small.

    • The blog publication-date window is deliberately not ported. Legacy applies blog_date &lt;= today to its listings (never to its own detail route), but FilterRequestType has no &lt;= operator, the only expressible workaround would force filter[inDates] as a Between and discard the client's own value — destroying archive-by-month browsing — and blog.blog_date is nullable, so a naive window would silently drop every NULL-dated article. filter[inDates] stays available as an opt-in, unchanged.

    • Two commented-out scaffolding stubs deleted from Advisable\Domains\Cms\Page\ListRequest — an isPublished entry in $defaultFilters and an order entry in $defaultSorts. Both were born commented in the commit that created the file and were never switched on; uncommenting the filter would have been the wrong fix for three mechanical reasons: $defaultFilters is a static property with no request context, so it can't consult isBackend() and would blind the admin listing too; its entries carry key = null, so a client ?filter[isPublished]=0 would AND with the default into an empty set instead of being overridden; and $filter['value'] ?: null coerces a falsy 0, so it cannot express "flag = 0" regardless. The correct mechanism is the per-request, context-aware one in the controller.

    • Still open. The five endpoints scope their own rows, but not the rows reached through a relation hop, and two of those hops still return hidden bodies to an unauthenticated caller. ?with=children.translations on /rest/cms/page returns the categories_mui bodies of unpublished pages, tracked under #639; ?with=articles.translations on /rest/cms/blog/category returns the blog_mui bodies of unpublished articles, tracked under #640. Both are zero-auth and bulk-enumerable in a single request, because index() embeds the named relation on every row it returns — the same #637 lesson as above: a forced filter scopes the endpoint it is registered on and nothing else, and a relation needs its own Relation::$visibilityScope. What this release closes on those two paths is the ordering oracle over them (the children.isPublished and children.active sort denials above) — not the row set.

    • Tests: new tests/Unit/Rest/Cms/Controllers/CmsStorefrontRowScopeTest.php (43 test methods; 19 run against all five endpoints via a shared endpoints() data provider — one class rather than five near-identical copies, since the shared three-part mechanism is the thing under test — and 24 are endpoint-specific, covering the Page/BlogArticle/BlogCategory sort denials at index/item/show and the harmlessness of the accepted root-sort side effect); and new tests/Unit/Domains/Cms/Page/ListRequestDefaultsTest.php (3 tests, pinning $defaultFilters/$defaultSorts as empty by reflection on the class defaults, and that isPublished stays a declared filter/sort for backend callers).

  • [4.121.0] fix(rest/cms): stop the guest blog-comment API leaking commenter emails and unmoderated comments (Advisable-com/ecommercen#616)

    • Why. GET /rest/cms/blog/comment (index, /item, /{id}) is open to anonymous callers, and it was returning every commenter's email address alongside the comment's moderation status — and serving pending and rejected comments together with approved ones. The same rows were reachable a second way, through GET /rest/cms/blog/article?with=comments and recursively via ?with=comments.children.
    • What made it more than a disclosure. The email column was also exposed as a partial (LIKE) filter and as an allowed sort — filter[email], sort=email and sort=children.email on the comment endpoint, sort=comments.email on the article endpoint. A LIKE probe plus an ordering oracle is a blind-prefix enumeration primitive over the whole blog_comments.email column, so an anonymous caller could harvest addresses belonging to comments it was never shown at all. Denying the filter alone would have been cosmetic: ordering by a withheld column still leaks its collation, one page at a time.
    • Serving rejected comments is the larger half. On one real tenant 4,195 of 4,932 comments are rejected (475 approved, 262 pending), and rejected blog content is typically spam or abuse — so this was an anonymous read of a shop's moderation reject pile, not a footnote to the email column. Approved rows exist on every tenant checked, so scoping to them does not blank the storefront.
    • The change, for storefront callers only — guest and authenticated customer; ResourceContext::isBackend() is the only exemption, and a null context denies. email and status are omitted from BlogCommentResource at every nesting depth (BaseResource propagates the context into children and parent). filter[status] is server-forced to approved via withMandatoryFilter(), so a supplied value is ignored rather than ANDed with the forced one — the repository ANDs same-column specs, so keeping both would have returned no rows and disguised the fix as an empty list. filter[email] and every email sort form are denied. show() gets a per-row 404, because it fetches by primary key outside the filter pipeline and the forced filter cannot reach it.
    • The relation rows are scoped with Relation::$visibilityScope, not by widening the existing scope (the #588 mechanism, whose one prior consumer is Product.categories). Two reasons: it is exempted for backend reads through HandlesRestfulActions::applyRelationVisibilityExemptions(), which the always-on scope is not — and an admin must see pending/rejected comments, since moderating them is the point — and it is applied by the shared relation loader on every load path, so it holds at every depth of an embed including the recursive reply tree. It is declared on comments (article) and on children and parent (comment), because a visibility scope is per-relation and does not cascade from comments into children; scoping only the article relation would have left ?with=comments.children and the comment endpoint's own ?with=children / ?with=parent open. The article's comments relation now carries both slots on purpose: scope keeps the structural parent_comment_id IS NULL invariant, visibilityScope carries the suppressible approved-only rule.
    • The approved value is the string 'approved', established from legacy, not guessed. blog_comments.status is enum('approved','pending','rejected') DEFAULT 'pending'; the legacy storefront read filters on that literal (Adv_blog_comments_model::getBlogComments():155) and the admin moderation write persists it (Adv_blog_comments_admin::getStatus():168-178). A numeric 1 — plausible because the admin URL passes numeric status params — would have hidden every comment on every blog.
    • Not a parity loss. The legacy storefront renders commenter name and body only and has never rendered an email address or a status (application/views/main/layouts/blog/blog_post.php:105-106), and it reads comments through a hard status = 'approved' filter. REST was exposing strictly more than the system it replaces, so closing it regresses no shipped behaviour.
    • New shared seam. GenerateListRequest::denySort() and HandlesRestfulActions::withDeniedSort() — the read-ordering counterparts of the existing denyFilter() / withDeniedFilter() pair (#452), which had no sort equivalent. Opt-in and empty by default, so every other controller is unchanged.
    • Deliberately out of scope. The blogComments flag in application/config/rest_features.php is discovery-only and gates nothing (only builder is in the guarded map), so it is untouched. Separately, the legacy per-row admin approve button passes 1 into getStatus(), whose switch matches only string cases, so it falls through to default and writes 'pending' — a pre-existing legacy defect, not touched or worked around here.
    • Tests: new tests/Unit/Rest/Cms/Controllers/BlogCommentScopeTest.php (28 tests) and tests/Unit/Rest/Cms/Controllers/BlogArticleCommentVisibilityTest.php (10); new tests/Unit/Domains/Cms/Blog/Comment/CommentVisibilityScopeTest.php and tests/Unit/Domains/Support/Request/QueryListBuilder/GenerateListRequestDeniedSortTest.php; tests/Unit/Rest/Cms/Resources/Blog/Comment/ResourceTest.php extended with the public/customer/backend and nested-relation cases; the #588 inventory guard in tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php updated to record the three new visibility-scoped relations deliberately rather than let them slip in.
  • [4.121.0] fix(rest/customer): withhold customer PII from anyone but an admin or the customer themselves, closing an unauthenticated leak through the slider endpoints (Advisable-com/ecommercen#615)

    • The defect. GET /rest/slider/slide?with=audience.customers and GET /rest/slider/slider?with=slides.audience.customers returned full customer PII — mail, address, city, region, postal, county, country, birthdate, gender, landphone, mobilephone, the whole sendto* shipping block, and companyName/companyAfm/companyDoy/profession/companyAddress — to a completely unauthenticated caller. It bypassed two policies at once: Audience::class requires backend + ADMIN/MARKETING, Customer::class requires backend + ADMIN/ORDERS. Verified against real tenant data rather than reasoned about: on the largest shipped tenant 89 slides carry an audience_id across 15 audiences and 677,807 shop_customer_audience rows, so the repro returned bulk PII.
    • Root cause — four ungated layers on a guest-readable chain. Slide, Slider and Group reads are auth => 'guest'. SlideResource embedded audience unconditionally, even though the audienceId scalar beside it was already admin-only — the isBackend() block gated the scalars and not the relation. AudienceResource embedded customers unconditionally. CustomerResource had no context gate on its base block at all. And RelationFilterMiddleware did nothing, because neither slider policy declared a relations key. A third chain existed too: Group.sliders → Slider.slides → Slide.audience, also guest.
    • The primary fix, and why it is at the serialization layer. CustomerResource now emits its personal fields only when the caller is backend or is the very customer the row belongs to. id stays ungated — a relation embed needs it for object identity and it is not personal data. A null context denies. This is the durable control rather than a policy allow-list because BaseResource propagates the ResourceContext to every nesting depth (addResourceToData()/addCollectionToData() both call setContext()), so the gate holds on every present and future path to customer data, whereas the middleware only ever inspects the first ?with= hop.
    • isAuthenticated() would not have been enough — the single most important detail here. On a guest-policy route an ordinary customer token resolves to scope customer, so it satisfies isAuthenticated() and would still have received the whole PII dump for every other member of an audience. The self-match is therefore an identity comparison, and it is deliberately type-robust: ResourceContext::getUserId() is typed ?string while the row id is an int on a hydrated entity and a numeric string straight from the CI3 driver, so a bare === would silently never match and the gate would fail closed on the /rest/customer/me self-fetch. Both sides are proven numeric and compared as ints, the shape Order::show()/Wishlist::show() already use.
    • dateRegistered, lang and country are inside the withheld set, deliberately. country is part of the postal address the issue reports as leaked, so splitting it from postal/city would leave the address half-open; dateRegistered is account-lifecycle metadata about an identified person; lang is that person's own preference. None has a storefront consumer for anybody but the signed-in customer. countryDetails (the #478 object) follows the same gate as the scalar it elaborates.
    • totalPoints is tightened, not left alone. It was gated on isAuthenticated() — the same too-loose gate this issue is about — so one customer could read another customer's loyalty balance through the same chain. It now follows the backend-or-self rule, which keeps both consumers its own docblock named (/rest/customer/me, admin) verbatim.
    • Nothing legitimate regresses. Every consumer of CustomerResource/CustomerCollection was enumerated before shipping: Cms\ContactEmail (backend-only), Order (auth => 'any', and Order::show()/enforceCustomerScope() already lock a customer caller to their own rows), Product\Wishlist (same shape), Plus\Audience (backend + ADMIN/MARKETING), and the Customer controller itself. 'any' does require isAuthenticated, so on Order and Wishlist the embedded customer is the caller and the self-match keeps ?with=customer working unchanged. GET /rest/customer/me and every admin customer read are untouched.
    • Two relations go admin-only (defence in depth). SlideResource.audience now follows its own already-admin-only audienceId scalar — audienceId is withheld from the response and audience targeting is applied server-side by SlideVisibilityFilter, so no storefront consumer loses anything. Withheld is not unknowable, though, and this gate does not make it so: filter[audienceId] remains an allowed guest filter and pagination.total is counted from the SQL-filtered set before visibility filtering, so ?filter[id]=X&filter[audienceId]=N still discloses the slide-to-audience mapping to an anonymous caller. That oracle predates this change and is tracked as #628 — it is not closed here; this closes all three chains at once. AudienceResource.customers is now backend-only: audience membership is a marketing-admin concern with no storefront use, and gating it also stops the roster being enumerated id-by-id from a guest route.
    • Policy allow-lists (Slide::class, Slider::class), explicitly defence in depth. Both now declare scope-keyed relations maps (the #551 mechanism) with the mandatory 'default' => []. ?with=audience is denied to guest and customer callers on /rest/slider/slide, so the audience is never eager-loaded there and the query cost stops being attacker-chosen. This does not close ?with=slides.audience.customers on Slider, and the tests say so out loud: RelationFilterMiddleware matches only the first hop (WithParser::topLevelName()), and slides must stay allowed for a storefront to render a slider at all. The resource gates above are the control.
    • Second defect in the same area — /rest/slider/slide had no slide visibility. GET /rest/slider/slider has dropped expired/not-yet-active slides and hidden audience-targeted slides the caller may not see since v1.22 (#483), but Slider::applySlideVisibility() lived in the Slider controller only and Slide::index/show/item were bare parent:: calls — so the rows the slider withheld were served straight from the bare endpoint. The existing SlideVisibilityFilter collaborator is now injected into the Slide controller (no new mechanism, no re-implemented predicates). index()/item() filter the result; show() returns 404 for a slide the caller may not see, because it fetches by primary key outside the list filter pipeline — the v1.26 Bundle::show() template. Filtering is post-fetch, so a page can return fewer rows than limit while pagination still counts the unfiltered set. Unlike the slider path the requested sort order is preserved: only the filter's visibility decision is used, never its ordering, so this endpoint's documented sort parameter is not silently overridden by a security fix.
    • Tests: new tests/Unit/Rest/Customer/Resources/Customer/PiiScopeTest.php (the gate, the type-robustness matrix, and the reported chain end to end), tests/Unit/Rest/Middleware/SliderRelationPolicyTest.php (allow-lists bound to the live config, incl. pinning 'default' => []), tests/Unit/Rest/Slider/Controllers/SlideScopeTest.php (filter wiring + the show() 404). ScopeFilteringTest's Customer section and two CustomerResource country tests were updated — the former had codified the ungated payload as expected behaviour.
  • [4.121.0] fix(rest/transporter): stop ?with=settings serving courier integration credentials to guests (Advisable-com/ecommercen#614)

    • The exposure. GET /rest/transporter?with=settings returned the whole transporters_settings table — courier integration credentials — to a completely unauthenticated caller, as did /rest/transporter/{id} and /rest/transporter/item (and every locale-prefixed twin). Live, not theoretical: populated credentials were found on 4 of 10 real tenants, under key names including PASSWORD, CLIENTSECRET, APIKEY, SECURITY_VALUE, CREDENTIALVALUE, PWD, UPWD, CPWD, SECD, UID, USER and CLIENTID.
    • Why it was reachable. Four things lined up. Transporter::class opens index/show/item with ['auth' => 'guest'] (application/config/rest_policies.php) so a headless checkout can list couriers before the visitor has an account. The settings relation is declared unscoped in Domains\Transporter\Transporter\Repository\RepositoryConfigurator. Rest\Transporter\Resources\Setting\Resource emitted transporterId, regKey and regValue with no ResourceContext gate whatsoever. And RelationFilterMiddleware returned early without filtering, because the policy declared no relations key. The relation embed therefore walked straight around the Setting endpoint's own policy, which is backend plus AUTH_ROLE_ADMIN.
    • The fix — a resource gate, which is the durable half. TransporterSettingResource now emits regKey and regValue only when ResourceContext::isBackend(). This is deliberately the primary control rather than a policy tweak: BaseResource::addResourceToData() and ::addCollectionToData() propagate the ResourceContext into every embedded resource and collection, so the gate holds at every nesting depth and on every path, present and future — including the second-hop ?with=transporter.settings route off a sibling sub-endpoint, which no allow-list can see. A null context counts as non-backend (deny by default), matching every sibling gate in src/Rest. transporterId is still emitted: it is the id the caller already supplied and is public on TransporterResource anyway.
    • regKey is withheld alongside regValue. The key names are the credential inventory — they enumerate which courier integrations a shop has configured and which secret exists for each — and nothing consumes them from a storefront context, so there was no reason to keep half the object.
    • The fix — a relations allow-list, which is defence in depth. Transporter::class now declares a scope-keyed relations map: storefront callers (public and customer) get translations only; backend keeps all eight; 'default' => []. This is explicitly not the control, because RelationFilterMiddleware matches WithParser::topLevelName() and therefore only ever inspects the first ?with= hop. It is the third relations entry in the file, after Customer (#551) and Line (#612).
    • All seven ADMIN-only sub-resources are denied, not just settings. CountyAvailability, OptionPricing, PostAvailability, PostPricing, Pricing, PublicMapping and Setting are each backend + AUTH_ROLE_ADMIN endpoints in their own right, so every one of them was reachable as a relation from a guest scope, and every one of them is now closed. postAvailabilities is included: ShippingCalculator consults PostAvailabilityRepository server-side to decide a courier's inclusion and never emits the availability table as data, so postcode serviceability reaches the storefront through POST /rest/checkout/shipping, never through this embed.
    • No storefront regression — the consumers were enumerated, not assumed. Nothing in the repo requests any relation on /rest/transporter: zero hits for rest/transporter anywhere in assets/, zero ?with= transporter hits in ecommercen/ or application/, and no controller test exercising ?with= on the endpoint. The rendered storefront's courier selector POSTs to the legacy /{lang}/api/transporters/getAvailableTransporters; the headless checkout gets its priced list from POST /rest/checkout/shipping (a plain array, no TransporterResource — src/Rest/Checkout/ contains no Resource classes at all), its pickup points from GET /rest/transporter/{id}/smart-point, and its external rates from the dhl-rates / asap-services endpoints. Each shapes its own flat payload. translations — the localized courier name — is the one relation with storefront value, and it stays open, v1.4 ?with=translations[el] scoping included.
    • Unchanged: reads stay guest, writes stay backend + AUTH_ROLE_ADMIN, every flat TransporterResource field, all filters, sorts and pagination. Backend callers see no difference at all.
    • Tests: new tests/Unit/Rest/Middleware/TransporterRelationPolicyTest.php (34 tests, bound to the live config so weakening the policy fails the suite) and a rewritten tests/Unit/Rest/Transporter/Resources/Setting/ResourceTest.php (6 tests, covering backend / customer / public / null-context and the nested-depth case); Transporter rows added to PolicyResolverIntegrationTest.
  • [4.121.0] fix(rest/product): scope product reads for storefront callers — inactive and soft-deleted rows are no longer guest-readable (Advisable-com/ecommercen#613)

    • Why. GET /rest/product/product is guest-readable and applied no row-visibility scoping whatsoever. ProductResource hides the active and softDelete fields from storefront responses, but nothing removed the rows — so an unauthenticated caller received inactive and soft-deleted products by default, with everything else about them (name, price, images, stock, vendor, category links) intact. Worse, the hidden rows were directly requestable: active and softDelete were in allowedFilters, so ?filter[active]=0 and ?filter[softDelete]=1 asked for precisely the withheld catalogue. And they were reachable through relations: ?with=lines.products walked back into the catalogue unscoped. This is the reference implementation for the class of endpoints tracked under #618.
    • The change — index() / item(). A protected enforceStorefrontProductScope() server-forces filter[active]=1 and filter[softDelete]=0 for storefront callers via HandlesRestfulActions::withMandatoryFilter(), the v1.26 Bundle template. A client-supplied value for either key is replaced, not combined — the repository ANDs same-column specs, so combining would return an empty set instead of the scoped catalogue. Both keys stay declared in allowedFilters and allowedSorts, so backend loses nothing.
    • show() is gated per row. show() fetches by primary key, outside the filter pipeline, so a mandatory filter cannot reach it; it fetches, checks active === 1 && soft_delete === 0, and 404s otherwise (the Bundle::show() shape). Fail-closed: a null flag reads as "not visible".
    • DECISION — show() deliberately 404s an inactive product, even though the legacy detail page renders one. ecommercen/eshop/controllers/Adv_products.php filters only soft_delete = 0, so legacy will render an inactive product's page. Copying that here would be more permissive than legacy, not parity with it, for two verified reasons. (1) shop_product.id is a sequential auto_increment — pharm16 runs min 1 to max 585,132 across 53,476 rows — so an unscoped fetch-by-id is itself a catalogue-enumeration primitive: walk the ids and you have the private catalogue, no filter needed. (2) Legacy's looseness is slug-addressed while show() is id-addressed; a storefront resolves a product page by slug through item(), so the visitor-facing behaviour for an inactive product is set by the index/item scope regardless of what show() does. The honest lever for a tenant that wants an inactive product to keep a live page is to keep it active = 1 and hide it another way — not an open id endpoint.
    • DECISION — price > 0 is deliberately NOT ported. Legacy's line/brand listing forces price > 0 (Adv_vendors::baseWhere()), and that clause is not part of this scope. Zero-priced products are predominantly gift SKUs. Measured across real tenant databases (soft_delete = 0 AND price &lt;= 0, intersected with the gift pool gift_choices.option_type = 1): pharm16 1,070 zero-priced of which 619 are in a gift pool — 88% of that tenant's 703-product pool; discount 2,496 / 1,241; demo 521 / 494; pharmacypoint 1,116 / 630; joy 1,033 / 399; livy 242 / 146. The business rule is "we do not sell zero-priced products unless they are gifts, shown as gift options" — a checkout constraint, enforced by the gift engine (src/Domains/Checkout/Gift/GiftMatcher.php) — so hiding the catalogue rows would break the exception rather than implement the rule: a headless storefront needs those rows to render a gift option's name and image. active = 1 already handles zero-priced debris (evripidis: 11,085 zero-priced rows, only 64 active, none gift-linked), and shop_product.price is NULLable, so a price > 0 clause would additionally drop every NULL-priced row.
    • The relation path — Line.products gets a Relation::$visibilityScope. New Advisable\Domains\Product\Product\ProductVisibilityScope supplies a closure forcing shop_product.active = 1 and shop_product.soft_delete = 0, attached to the products relation in Product\Line\Repository\RepositoryConfigurator — the #588 mechanism, and the piece deferred out of #612. This is what closes ?with=lines.products, and it is why a relations allow-list could not: RelationFilterMiddleware matches only the first ?with= hop (WithParser::topLevelName()), so ?with=lines.products has top-level name lines and walks straight past any allow-list on Product. A visibilityScope instead applies at the loader, at every nesting depth and for non-REST consumers too. It is declared as $visibilityScope (suppressible), not $scope (always-on), precisely so admins can still see past it.
    • Backend callers are unaffected, and that is the mechanism that answers "how does an admin still get them". enforceStorefrontProductScope() returns early under ResourceContext::isBackend(), show()'s gate is skipped for backend, and the relation scope is suppressed centrally by HandlesRestfulActions::applyRelationVisibilityExemptions() granting Relation::VISIBILITY_EXEMPT_ALL. An admin listing carries no filters at all, both keys stay filterable and sortable, show() returns inactive and soft-deleted rows, and ?with=lines.products stays unscoped at any depth.
    • Not done here, deliberately: storefront sort denial on active / softDelete. The shared seam for it (HandlesRestfulActions::withDeniedSort() / GenerateListRequest::denySort()) lands with #616 and must not be duplicated locally. Deferring is safe because of the forced filters: every row a storefront can now receive already has active = 1 and soft_delete = 0, so ordering by either column orders by a constant and partitions nothing. The pinning test is test_a_storefront_sort_on_active_cannot_partition_the_scoped_set() — if anyone relaxes the forced filters, that is where the sort becomes an oracle again and the denial becomes load-bearing.
    • Tests: tests/Unit/Rest/Product/Controllers/ProductScopeTest.php grows a #613 section (33 tests in the file total) and tests/Unit/Domains/Product/Product/ProductVisibilityScopeTest.php is new (8 tests, covering the closure's clauses, the live Line.products wiring, and the nested lines.products load with and without an exemption). The gift-pool regression guard is test_a_zero_priced_active_product_is_still_served_to_a_guest(). RelationConfigurationTest's visibility-scope guard is updated from "categories is the only one" to the reviewed two-entry list.
  • [4.121.0] feat(rest/product): open Line read endpoints to guest, with a relations allow-list withholding products (Advisable-com/ecommercen#612)

    • Why. GET /rest/product/line, /rest/product/line/item and /rest/product/line/{id} (plus their locale-prefixed twins) required a backend token with ADMIN or PRODUCTS, so an entire content type was unreachable to the storefront even though it is fully modelled, routed and serialised. The legacy storefront serves the same content to anonymous visitors: vendors/{brand}/{line} resolves to a line listing via Adv_vendors::baseVendor(), which looks the second URL segment up in shop_line scoped by vendor_id, then hands off to ::lines_list(). A headless storefront had no way to serve that route, so links to brand-line pages had to point at the production website instead of staying inside the app.
    • The change. The Line::class row in application/config/rest_policies.php gains a methods block opening index/show/item to guest, mirroring the already-guest Vendor::class sibling and following the #484 Badge precedent. store/update/destroy are unchanged — still backend + ADMIN/PRODUCTS. Resource fields, filters and sorts are all untouched. Resolving a line needs both filter[vendorId] and filter[slug.{locale}], because line slugs are unique per vendor rather than globally.
    • No row scoping, because there is nothing to scope on. shop_line and shop_line_mui carry no published/active/status column (verified against database/initial/initial.sql and every subsequent migration), so — unlike Product or Category — every row is public content, which is exactly how legacy treats it (Adv_lines_model::getMuiRecords filters on lang only, and Adv_vendors::vendors() lists all of a vendor's lines).
    • ?with=products is denied to storefront callers — defence-in-depth, not a closed leak. The policy now declares a scope-keyed relations allow-list — the second entry in the file, after Customer (#551). Line.products is an unscoped many-to-many onto Product with no scope and no Relation::$visibilityScope, and the Product repository applies no default active/soft-delete scope, so a guest embed through this endpoint would have returned inactive and soft-deleted products, where the legacy line listing forces active=1 / soft_delete=0 / price>0 (Adv_vendors::baseWhere()). Guest and customer callers get translations and vendor; backend callers keep the full set. A denied relation is stripped silently and the request still returns 200, exactly as an unknown relation would be. But the allow-list only stops the Line endpoint from being a further route to that data: Product::class is itself guest-readable, declares no relations allow-list, and relation loading recurses across entity boundaries, so GET /rest/product/product?with=lines.products reaches the same inactive/soft-deleted rows with Line's policy never consulted — and more directly, a guest can already request them via GET /rest/product/product?filter[active]=0, since the Product endpoint applies no row scoping at all. Both gaps are pre-existing and unchanged by this branch (ProductResource serialises lines on develop too, and Product::class's policy is untouched here); they're now tracked as #613. The allow-list is still the correct shape for the day Product gets scoped — it just doesn't close the exposure by itself.
    • Bug found and fixed on the way in. RelationFilterMiddleware matched allow-list entries with explode(',', $with) then explode('.', $segment)[0], which mis-reads the v1.4 per-relation language grammar in two ways: ?with=translations[el] yields the top-level name translations[el], which matches nothing and gets dropped; and ?with=translations[el,en] splits on the comma inside the brackets into translations[el + en], so the relation is lost and any surviving sibling is re-imploded around the wreckage. The middleware now splits and names segments through WithParser, which owns that grammar, so a bracketed relation is matched by its name and re-emitted verbatim. No shipped endpoint was affected — Customer was the only policy with an allow-list and no client sends a bracketed ?with=country — but every future allow-list, starting with this one, would have hit it.
    • Second middleware hardening. ?with[]=x arrives as an array, which the middleware passed straight into a string-typed call — a TypeError the dispatcher turns into a 500. It was unreachable in practice while Customer was the only policy with an allow-list (it needs a customer token, and no client sends that shape), but declaring one on a guest-readable endpoint would have made it triggerable by any anonymous caller. A non-string with is now dropped, matching how WithParser::parse() already treats it.
    • Tests: new tests/Unit/Rest/Middleware/LineGuestReadPolicyTest.php (19 tests) binding to the live config; Line rows added to PolicyResolverIntegrationTest; splitRelations()/topLevelName() coverage added to WithParserTest.
  • [4.121.0] fix(admin/orders): restore guest/registered customer icon distinction in the admin orders list (Advisable-com/ecommercen#611)

    • The gap. Commit 4a52f9b00 ("feat(GiftCardOrders): introduce gift card order management and history views") unified the customers_admin/guest_history and customers_admin/quick_history links into a single customers_admin/order_history action, but collapsed the icon conditional along with it — leaving one hard-coded glyphicon-eye-close. Since 4.119.x every row in the admin orders listing has rendered the guest icon regardless of is_guest, so admins could no longer tell registered customers from guests at a glance. Reported by two client sites (easy-pharmacy, acropolispharmacy) — Freshdesk #23493.
    • The fix. application/views/admin/orders/list.php keeps the unified order_history link (that consolidation was correct) and restores the icon conditional keyed on $row->is_guest: glyphicon-eye-open (registered) vs glyphicon-eye-close (guest), with titles from the existing eshop.admin.panel.customers.registered / .guest lang keys. No backend change needed — is_guest is already selected by Adv_order_model::getOrdersListAdminResults(). Mirrors the never-broken pattern in application/views/admin/customers/list.php.
  • [4.121.0] fix(eshop): coerce order_serial to string at its four write/read seams so it always matches its varchar column (Advisable-com/ecommercen#609)

    • The bug. Adv_order_model::createSerial() (ecommercen/eshop/models/Adv_order_model.php) returned a PHP int whenever the ordersPrefix config item is empty — and empty is the shipped default (application/config/app.php:260), so this is the stock configuration, not a per-client quirk. The serial originates as $this->db->insert_id() in processOrder() and, on the no-prefix/no-collision path, came back unchanged as an int, then flowed as order_serial into 26 serial-keyed DB operations on the order-placement leg. CodeIgniter 3's query builder does not quote a PHP int, so the emitted SQL read WHERE order_serial = 747320 against shop_order.order_serial, a varchar(255) indexed column. MySQL resolves an int-vs-varchar comparison numerically, coercing the column and making the order_serial index unusable for that predicate — confirmed via production EXPLAIN, where order_serial sat in possible_keys but was never the chosen key, and the optimizer instead drove the join from a full scan of shop_customer (371,481 rows).
    • Customer impact. The "redirecting to bank" page rendered incomplete and then timed out with a Cloudflare 503 after ~30s (reported on the Eurobank/CardLink redirect path). The blast radius is wider than one gateway: 17 of the 19 payways Adv_checkout::run() dispatches are affected — 6 get_records_customer() SELECTs, 9 getOrder() SELECTs, and 11 set_status() UPDATEs — which includes _delivery, _bank_transfer and paidAtStore, so cash-on-delivery, bank-transfer and pay-at-store orders were affected too, not just bank redirects. The 11 UPDATEs are the worse case: under InnoDB's default REPEATABLE READ, a full-scan UPDATE takes row locks on every row it scans, amplifying lock contention under concurrent checkout. Only the order-placement leg is affected — payment response/callback legs rebuild their payload from a DB read, and mysqli returns column values as strings, so those were always correctly quoted.
    • The fix. Confined to ecommercen/eshop/models/Adv_order_model.php (+26 lines, no existing line altered), four coercion points: createSerial() casts $insertId to (string) as its first statement (the root fix, alone sufficient for all 26 call sites); set_status() gets a null-safe (string) coercion of $orderSerial, guarded on !== null so WHERE order_serial IS NULL doesn't become WHERE order_serial = ''; getOrder() writes back the (string) cast inside its existing guard block, deliberately without trimming; get_records_customer_base() gets an isset()-guarded (string) coercion of $conditions['order_serial'] so a null still renders IS NULL. Casting only at the 26 call sites was rejected at triage — it re-breaks the moment a new payway is added.
    • No migration and no patcher: stored shop_order.order_serial values were already strings in the column: the int existed only in-process, between insert_id() and the query builder.
    • REST is unaffected: the modern REST/Domain checkout never calls createSerial() — PlaceOrderService.php:382 builds its serial as str_pad((string) $order->id, 6, '0', STR_PAD_LEFT), already a string.
    • Tests: tests/Legacy/Eshop/AdvOrderModelProcessOrderCommitTest.php gains 3 tests asserting createSerial() returns a string on all three branches (no-prefix, prefix, collision); its query-builder double gained an optional array $collisionCounts = [] third parameter so the collision branch is reachable at all. File is now 13 tests / 38 assertions (was 10 / 31); tests/Legacy/Eshop overall is green at 400 tests / 1239 assertions.
  • [4.121.0] fix(order): stop writing shop_order.cart_contents from the REST order-create path (Advisable-com/ecommercen#605)

    • The bug. PlaceOrderService::placeOrder() (src/Domains/Checkout/PlaceOrderService.php) built a serialized cart snapshot and assigned it to $orderData['cart_contents'] on every REST order placement, then handed that array through OrderWriteService::create() → Order\WriteData → the write repository's INSERT. shop_order.cart_contents is not a real column: no Phinx migration and no statement in database/initial/initial.sql creates it. Every REST order placement therefore failed with SQL error 1054 (Unknown column 'cart_contents' in 'field list') on any environment provisioned from what the repo ships — surfaced as a CodeIgniter HTML error page rather than a JSON error response, i.e. every REST checkout was broken out of the box.
    • Not a null-emission bug. BaseWriteRepository::insert() already strips null values from a payload before building the INSERT, so a null cart_contents was never the problem. The removed code built and assigned an always-non-null serialized snapshot string; that non-null value is what named the column and broke the INSERT.
    • The fix. PlaceOrderService no longer builds or assigns cart_contents — the assignment plus the buildCartContentsSnapshot() / decodeCartItemOptions() helpers that existed only to build it are removed (112 lines). Order\WriteData drops the cartContents constructor property, its fromArray() read and its toArray() map entry, so no REST write path can emit the column any more. Order\Repository\Entity's @property string|null $cart_contents annotation is removed too — it described a column that was never actually there.
    • The column is deliberately not created. No migration ships, and none is planned. A full inventory of every cart_contents reference across src/, ecommercen/ and application/ found zero SELECTs anywhere — nothing reads the column back. Legacy checkout doesn't persist it either: Adv_order_model builds the same value only in memory and unset()s it before its own INSERT.
    • Legacy untouched. No file under ecommercen/ changed — the payway handlers keep reading $checkoutData['cart_contents'] from the in-memory order array exactly as before.
    • Tests: tests/Unit/Checkout/PlaceOrderServiceTest.php's cart-contents test is replaced by test_order_insert_payload_carries_no_cart_contents_column(), asserting on the array that reaches the write repository (not the payload PlaceOrderService hands the write service), so the guarantee survives a future refactor that re-adds the field to WriteData. tests/Integration/Domains/Order/Order/ServiceTest.php gains a database-backed test that first asserts shop_order.cart_contents genuinely does not exist in the schema, then confirms create() succeeds when handed a non-null cart_contents value in the payload (the key is dropped before the INSERT, rather than failing).
  • [4.121.0] fix(customer): route change-password write through the model's overridable hashing seam (Advisable-com/ecommercen#603)

    • The bug. POST /rest/customer/me/password (src/Rest/Customer/Controllers/Customer.php) verified the customer's current password through Adv_customer_model's overridable hashing seam — checkCustomer() → checkPassword() — but wrote the new password using a private copy of the default hashing scheme inlined in the controller itself, generateEncryptedPassword(). On a stock tenant the two schemes happened to coincide, so the bug was invisible. On any tenant that overrides the model's hashing — which the seam exists for — the endpoint read with one scheme and wrote with another, so the customer's stored hash could never again match their own tenant's verifier. The failure was silent: the endpoint still returned 200 {"message":"Password changed successfully."}, and forgot-password was the only way back into the account.
    • The fix. generateEncryptedPassword() is deleted from the controller. Adv_customer_model (ecommercen/eshop/models/Adv_customer_model.php) gains a new public method, changePassword(int $customerId, string $newPassword): bool, placed after the existing resetPassword(). It hashes via the model's own protected generateEncryptedValues() seam and writes via the bare updateCustomer(), returning whether a row actually changed. The controller now makes a single $ci->customer_model->changePassword(...) call. Request parsing, validation, status codes and response bodies are unchanged.
    • Tests: new tests/Legacy/Eshop/AdvCustomerModelChangePasswordTest.php (4 tests) asserting the persisted value is exactly what the stubbed hashing seam produced.
  • [4.121.0] refactor(cart): resolve the cart's [productId => qty] map once, batched, instead of one product-code query per cart line (#596)

    • Why. Two byte-identical private methods — GiftMatcher::cartProductQuantities() and Advisable\Rest\Cart\Controllers\Cart::cartProductQuantities() — each turned the cart's product_code_id-keyed lines into the [productId => totalQty] map the gift engine and both gift presenters consume, and each did it with one ProductCodeRepository::get() per cart line. An N-line cart therefore cost N queries on the order-placement path and another N on every /rest/cart render, and the duplication meant a fix to either copy silently missed the other.
    • One shared collaborator. New Advisable\Domains\Cart\CartProductQuantityResolver (src/Domains/Cart/CartProductQuantityResolver.php), with a single resolve(CartItemEntity[]): array&lt;int,int>. Both call sites delegate to it and the duplicated loop exists in neither any more. It sits beside CartTotalsCalculator / CartWeightCalculator in Domains\Cart and is registered under // Business services in src/Domains/Cart/container.php; CartWeightCalculator is the precedent — a small single-purpose collaborator injecting only ProductCodeRepository and shared by a domain service and a REST controller alike.
    • Query count is now CONSTANT in the cart's line count, not linear. Two passes: the parent product id is read straight off the already-hydrated productCode relation where the caller loaded one (Cart::CART_RELATIONS on the render path, CartTotalsCalculator::PRICING_RELATIONS on the checkout paths), mirroring CartWeightCalculator::resolveUnitWeight(); anything left over is fetched in one batched read via match(new Filter('id', $ids, 'IN')), the 'IN' operator being the branch that reaches where_in() (src/Domains/Support/Repository/Specification/Filter.php:23-26). Because every current caller hydrates productCode, the practical cost on both paths is zero product-code queries; an unhydrated caller pays exactly one, whatever the cart's length.
    • Immediate effect. All three GiftMatcher::match() call sites benefit today: order placement (PlaceOrderService.php:99), every GET /rest/cart render — a storefront re-renders the cart on each add/update/remove — and POST /rest/checkout/totals, the most frequency-sensitive of the three, since headless storefronts call it on every transporter selection and on a debounced destination/address change. /totals reaches the same seam unconditionally via quoteGifts() (src/Rest/Checkout/Controllers/Checkout.php:702), so this path is live now, not prospective.
    • No behaviour change anywhere. The skip semantics are preserved verbatim: a line whose product code no longer resolves, or whose code carries no product_id, contributes nothing and does not abort; lines sharing a product SUM; the (int) casts on product_code_id and qty are kept. Map keys stay in first-seen cart order.
    • An empty id list is guarded explicitly. Filter hands an empty array straight to where_in(), which emits no WHERE clause — so a batch with nothing to look up would have selected the entire product_codes table. The resolver skips the query outright in that case (which is also the empty-cart case), and a test pins it.
    • Tests. New tests/Unit/Cart/CartProductQuantityResolverTest.php (aggregation, both skip branches, string-column coercion, the empty-list guard, the hydrated fast path, a mixed cart, and the shape of the IN filter itself). New tests/Unit/Rest/Cart/Controllers/CartGiftQuantityMapTest.php — the first test of any kind for src/Rest/Cart/Controllers/Cart.php — pinning that buildCartResponse() resolves the map through the shared collaborator and hands the identical map to CartGiftPresenter and CartGiftNearMissPresenter. GiftMatcherTest keeps all seven existing cases and gains a call-count pin; it wires the REAL resolver around a mocked ProductCodeRepository rather than mocking the resolver, since a bare mock would return an empty map and quietly vacate every assertion in the file.
    • No REST contract change — GET /rest/cart returns the same payload, with the same gifts and giftsNearMiss blocks. No DB migration, no config keys, no language keys.
    • Out of scope, deliberately. A request-scoped identity map on BaseRepository (a platform-level change that needs its own argument), and the third per-line ProductCodeRepository::get() of the same shape at src/Domains/Checkout/OrderBasketBuilder.php:288.
  • [4.121.0] fix(cart): compute cart_total_vat() from the parsed cart so the coupon gate matches the charge (#594)

    • The bug — two disagreeing expressions of one number. Adv_coupons_model::isValidCoupon() weighs an opaque $cartTotal against a coupon's total_cart_from / total_cart_to (Adv_coupons_model.php:431-439), and its callers disagreed about what that number was. AdvCartResource (the cart widget) passed baseParseCartContents()'s cart_total_vat — bundle-aware and rule-13-aware. Adv_order::checkCoupon(), Adv_order::previewOrderCouponData() and AdvApiPreviewCoupon passed the cart_total_vat() helper, which was blind to both. The helper re-fetched its own live data via getLiveProductsParsed() and summed final_price * quantity against the raw cart quantity, so it missed applyBundlePricingToCartLiveData() (Adv_order_model.php:543) and the rule-13 $paidQty reduction (:580-585). Both gaps push the same way, so the helper always returned a total greater than or equal to the parsed one. Net effect: on a total_cart_from coupon, checkout granted a discount the cart widget rejected — the money-losing direction — and on a total_cart_to coupon it did the reverse. Divergence needed only one of PRODUCT_BUNDLES.ENABLED or a single live rule-13 gift (that adjustment path runs unconditionally, with no feature flag).
    • The fix. cart_total_vat() (ecommercen/helpers/cart_helper.php) now delegates to Adv_order_model::baseParseCartContents() and returns its cart.cart_total_vat, so there is one expression of the gross items-only subtotal instead of two. The helper loads eshop/order_model itself rather than trusting the caller: the model is not autoloaded, and the helper is reached from application/views/production/google_header.php, which renders on every storefront page under controllers that never load it.
    • The result is memoised per request — a function-local static array, keyed on md5(serialize($cart->contents()) . '|' . serialize($vatProbe)). This is a requirement, not an optimisation: baseParseCartContents() is strictly more expensive than the old helper body (two product_parser_model->get() passes plus the rule-13 gift fan-out), and the helper can fire more than once per render. Because AdvCartResource already calls baseParseCartContents() on the cart render, that page now does less total work than before. pscache was deliberately not used: it is persistent and cross-request, and its key derivation hashes only the method name and arguments — baseParseCartContents() takes none, so every session would collide on one key and read another customer's cart total.
    • The memo key includes a vat() probe, not just the cart. final_price is VAT-derived (Adv_product_parser_model.php:131, :475), and the VatForOrder singleton is reconfigured in place mid-request by Factories::vatForOrder() — Adv_order::checkCoupon() calls setVatForOrder() four lines before it reads this helper. A cart-only key would have served the pre-mutation total to the post-mutation read. AdvVatForOrder exposes no getter for any of its four protected properties, so the key probes the observable behaviour of vat() over [0, 6, 13, 24] instead. That is also strictly more correct than reading state would have been: it captures a fork whose invoiceVat() / receiptVat() override keys on something the core properties do not express.
    • Admin order editor — coupon basis corrected. Adv_orders_admin::coupon_in_cart() seeded $cart['cart_total_vat'] from the posted cartTotal (Adv_orders_admin.php:1459 before this change; now :1466), but gated isValidCoupon() on cartItemsTotal (:1470 before; now :1477). The two start identical in application/views/admin/footer_js.php:2559-2560 and then diverge: only cartTotal is mutated into a display grand total (+= deliveryCost :2573, += transferCost:2580, += giftPackagingCost :2604, -= couponDiscount :2612). So the threshold gate and calculateCart()'s discount basis were different numbers, and — because couponDiscount had already been subtracted in the browser — recalculating deducted the discount a second time. That seed line now reads $orderData['cartItemsTotal'], the gross items-only subtotal and the exact analogue of baseParseCartContents()'s cart_total_vat. The isValidCoupon() gate was already correct and is unchanged.
    • Deleted the dead cart_total() helper. Zero callers repo-wide; it duplicated the old, wrong cart_total_vat() body.
    • Tests. New tests/Legacy/Cart/CartHelperTest.php pins delegation (the right key off the right method), once-only invocation across repeat calls, cart-change invalidation (both a quantity bump and a same-quantity product swap — the latter is why the key is the serialized contents and not total_items()), VatForOrder-state invalidation with a byte-identical cart, and the removal of cart_total(). It swaps the CI super-object via GetInstanceRegistry::set() and installs a real reflection-built VatForOrder into Singleton::$instances, restoring both in tearDown(). Verified non-vacuous by mutation: disabling the memo, dropping the VAT probe from the key, and reading cart_total instead of cart_total_vat each fail a distinct test.
  • [4.121.0] feat(feeds): gate per-feed XML price modifiers behind an Advisable-only master switch (Advisable-com/ecommercen#592)

    • Why. Any merchant admin with settings access could set per-feed XML price modifiers, which change the prices actually published to price-comparison engines and marketplaces (Skroutz, Google, Facebook/Meta, BestPrice, Glami, eMAG, Public Marketplace and the rest). There was no way to withhold that capability from a shop. It is now gated behind a switch only Advisable staff can reach.
    • The switch. New registry key XML_FEEDS.CUSTOM_PRICE_ENABLED, surfaced as checkbox xmlPricingEnabled on settings/only_advisable (the Advisable-staff-only page), following the same panel pattern as the existing enableFilterCategoryGroupTags toggle. New lang keys eshop.admin.only_for_advisable.enable_xml_pricing.{header,label} in all 8 locales (chinese, english, french, german, greek, italian, russian, spanish).
    • The mechanism — the interesting decision here. Flipping the switch ON→OFF resets all 14 per-feed XML_FEEDS.CUSTOM_PRICE_&lt;FEED> flags to false, and that reset is the enforcement. Every existing consumer — the 13 feed controllers, the AdvUpdatePublic2PSupplier job, getEnabledCustomPriceXmlFeeds() and everything downstream of it — already gates on those per-feed flags and stops on its own. So no consumer was touched: the change is confined to the settings controller, two settings views, the seeder and the lang files, rather than threading a second flag through 14 read sites. Implementation in ecommercen/settings/controllers/Adv_settings.php (~lines 2014-2039); the reset fires only on a genuine ON→OFF transition (the prior value is read with ?? 1, so an absent row counts as ON), never on deploy and never on an OFF→OFF save.
    • What the reset deliberately preserves. The 14 CUSTOM_PRICE_&lt;FEED>_MODIFIER percentages and shop_product_feed_lp.price_modifier are left untouched, so a shop's configured values are still there if the switch is turned back on.
    • The settings page. While the gate is off, the "Custom τιμή" and "Ποσοστό αλλαγής Τιμής (%)" fields are hidden in all 14 blocks of settings/xml_feeds_settings (Public 2P included — that page renders a 14th block with no feed controller of its own). Hidden server-side with a PHP if, not CSS or JS, so the inputs are genuinely absent from the POST.
    • Fixed — the null-wipe this needed to avoid. Precisely because those inputs stop POSTing, an unguarded save of that page would write the absence back to the database: (bool)null over all 14 CUSTOM_PRICE_* flags, null over all 14 _MODIFIER values, and nulls pushed into per-product shop_product_feed_lp.price_modifier via updateXmlFeedCustomPriceModifier() — destroying the very percentages the design promises to preserve. All three writes are now skipped while the gate is off (Adv_settings.php:1670 and :1705).
    • Upgrade behaviour — no migration, deliberately. Reads use ?? 1, so existing installs keep publishing custom prices exactly as before on deploy. Only InitialSeed writes an explicit 0, and only on a genuinely fresh install (gated on the absence of a SEEDER.INITIAL_SEED_RAN row, snapshotted at the top of run()), so new shops start gated while re-running the seeder against a live shop is a no-op. That guard is not cosmetic: this is the one registry insert in that file whose seeded value is the opposite of its read-time default, so an unguarded re-run would silently disable custom XML pricing platform-wide. ?? rather than ?: is load-bearing — ?: would treat an explicitly-OFF switch as absent and flip it back ON.
  • [4.121.0] feat(captcha): layer invisible reCAPTCHA v3 scoring on top of v2, with v2 as a step-up challenge (#586)

    • What. Contact form, return form, and the waiting-list AJAX flow now mint a reCAPTCHA v3 token and verify its score server-side. A score below a configured threshold reveals the existing v2 widget in place (no page reload for the waiting list) as a step-up challenge, exactly as v2 already required. A site with no v3 keys configured is unaffected — it keeps today's exact v2 behaviour.
    • Ships inert. ecommercen/core/Adv_front_controller.php still uses the no-op CaptchaTrait. v3 activates only for a client that overrides in GoogleRecaptchaTrait and fills in both v3 keys — there is no separate on/off toggle.
    • New service. src/Recaptcha/GoogleRecaptchaService.php (+ readonly DTO src/Recaptcha/RecaptchaResult.php) — a Guzzle siteverify client with explicit timeout/connect_timeout, structured logging on the recaptcha log channel, and fail-closed handling of transport failures and malformed JSON.
    • Per-form v3 actions (contact_form, return_form, waiting_list) are verified server-side against what Google returns. An action mismatch — or a response with no action at all — fails closed rather than being accepted, so a token minted on a cheap public page can't be replayed against a protected form. An action mismatch does not offer the v2 step-up: it signals misconfiguration or attack, not a borderline human.
    • New admin config (Settings → Google, registry group GOOGLE):
      • RECAPTCHA_V3_KEY — plaintext.
      • RECAPTCHA_V3_SECRET — encrypted; saving it now requires a working APP_ENCRYPTION_KEY. Saving with a blank/broken encryption key is now refused with a visible admin error instead of silently storing a corrupt value (previously read back as 0, which looked like a saved-but-wrong secret).
      • RECAPTCHA_V3_THRESHOLD — numeric, 0..1, default 0.5; a non-numeric stored value falls back to 0.5.
    • Merged with #630. #630's set_message('captchaCheck', t('captcha.validation.error')) and t('captcha.field.label') field label now apply to all three rule-binding branches this feature introduced — the v2 fallback, the v2 step-up retry, and the v3 hidden field g-recaptcha-v3-response. The v3 field carries the same label as the v2 widget: to a shopper both are just captcha verification, and which one a request got is an implementation detail of the score check. #630 deliberately shipped without tests because tests/Unit/Core/GoogleRecaptchaTraitTest.php and FakeFormValidation only exist on this branch; that coverage is added here (FakeFormValidation::set_message()/labelFor() plus a data-provider test pinning the message and label on each branch).
    • Language keys added to all 8 locales: settings.admin.google.recaptchaV3Secret.encryptionError (adv_advisable_lang.php) and waiting_list.error_captcha_step_up (adv_theme_lang.php).
  • [4.121.0] fix(gift-cards): make gift card order acceptance idempotent to stop double coupon issuance (Advisable-com/ecommercen#583)

    • The bug. AdvGiftCardOrdersModel::acceptGiftCard() (ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php) had no status guard: it unconditionally inserted a coupon and marked the order Completed, regardless of the order's current state. A duplicate accept — a payment-gateway webhook retry, a refreshed/re-POSTed bank return URL, or an admin double-clicking Accept — minted a second fully redeemable coupon for the full face value and orphaned the first by overwriting coupon_id on the order. The orphan is structurally invisible to redemption: isValidCoupon() → getCouponsRecordByCode() (ecommercen/coupons/models/Adv_coupons_model.php) resolves by coupon CODE with a bare WHERE coupon = ? and never joins gift_card_orders. Because the old code also reset email_sent/sms_sent on every call, a delivery-cron tick landing between two accepts could leave the customer holding two live full-value gift-card codes. Only XPay and Piraeus guarded against this, and only at the caller — Viva, PayPal Advanced, the other bank return URLs, the reconciliation job, and the admin path did not.
    • The fix. The model now claims the order before issuing the coupon: a single conditional UPDATE … WHERE id = ? AND gift_card_status IN (…) both checks and writes, so two concurrent accepts serialise on the row lock and the loser matches zero rows. acceptGiftCard() now returns bool (false = already handled, nothing written). A read-then-write if ($status === Pending) was deliberately rejected — two retries could both read Pending before either writes.
    • Source-state policy. Automated callers (legacy + REST Viva webhooks, the bank return URLs via AdvGiftCardPage::successView(), the XPay hook, all three AdvCancelPendingGiftCards branches) may accept a Pending order only. The admin manual accept uses a new acceptGiftCardManually(), which additionally allows Canceled — staff confirming a bank transfer that arrived after the cron already cancelled the order. No caller may re-accept a Completed order. The existing XPay/Piraeus caller-level guards were kept — they decide which outcome is rendered (a Completed re-POST must still show success), not whether the write is safe.
    • Admin API. POST /admin/giftCards/acceptGiftCard/{orderId} now answers 409 Conflict with {"error": "This gift card order is already completed."} when the order is already Completed, and skips the accept post-actions; 404/500 behaviour is unchanged. No public REST/API contract changed. The REST Viva webhook twin (src/Rest/Webhooks/Controllers/Webhook.php:336-348) did gain the same Pending-only guard and an info log on a refused accept, but it still answers 200 on both paths — the log is observability only.
    • Secondary fix. A Canceled → Completed admin accept now clears canceled_at — without it the order would resolve as Completed by status but vanish from the admin Completed filter (applyStatusFilter() requires completed_at IS NOT NULL AND canceled_at IS NULL) and appear under Canceled instead. Consistency cleanup, not a correctness fix. gift_card_status is now written as the raw enum value instead of relying on Spatie\Enum\Enum::__toString(); the stored value was identical before and after (MySQL coerced the stringified value the same as the raw int), so there was no data-corruption bug and nothing to remediate in existing rows — this just matches every other write in the model instead of leaving one path dependent on implicit stringification.
    • Tests: new tests/Legacy/GiftCards/AdvGiftCardOrdersModelTest.php (10 tests, 40 assertions) covering duplicate accept on Completed/Canceled via both the automated and admin paths, the canceled_at clear, the admin-filter resolution, claim-before-issue ordering, and NotFoundException propagation. Two existing spies widened void → bool.
  • [4.121.0] fix(admin): preserve the merchant's saved payment-method order on the settings redisplay (Advisable-com/ecommercen#553)

    • application/views/admin/settings/payment_settings.php redisplayed the storefront and gift-card payways panels in the fixed allPayWays() sequence instead of the merchant's saved order — array_intersect_key() returns entries in its first operand's order, and that operand was the fixed sequence, not the saved list. Both panels now build the selection with array_merge($payway_methods_keys, $selected_payways_match), matching the already-correct admin panel already using that form on the same page.
    • Because the settings page is a single form and Adv_settings.php:1067 writes the payway order back unconditionally on any successful save, saving any unrelated field on this page silently persisted the wrong order — customer-visible at checkout on the next visit. Both affected panels are fixed; the admin payways panel was never affected and is unchanged.
  • [4.121.0] chore(eshop): move the product-page hit-counter write after the read-heavy render path (Advisable-com/ecommercen#549)

    • The change. Adv_products::indexExtras() (ecommercen/eshop/controllers/Adv_products.php) called updateProductHits() as its 2nd statement, ahead of the review/related-products/recommendations/blog-article render pipeline that makes up the rest of the method. It now calls updateProductHits() as the method's last statement, after renderProductBlogArticles(). Pure reorder — a single line moved, nothing added or removed.
    • Scope. This is a reorder within indexExtras() only. Deferral via deferred_task / post_system was explicitly ruled out of scope: DeferredTaskRunner is designed for lossy work (in-memory only, lost on worker kill, exceptions swallowed to a log), and spending real correctness for a benefit that is currently zero is the wrong trade.
    • Out of scope, filed separately. Adv_product_model::updateHits() is a non-atomic SELECT *-then-UPDATE lost-update race — real, but that is issue #667 and is untouched here. application/libraries/Pscache.php is untouched — that is issue #666.
    • Verification. indexExtras() has no early return / exit / redirect / throw / show_404 between the old and new call sites (verified by grep over the method body, zero matches), so the reorder cannot cause the increment to be skipped on any path that used to run it.
  • [4.121.0] fix(customer): enforce MessageChannel enum on customer-message-history write path (Advisable-com/ecommercen#497)

    • The gap. POST /rest/customer/customer-message-history and POST .../{id} (backend-only, ADMIN/MARKETING) let messageType — the delivery-channel column — accept any string up to its varchar(50) cap. src/Domains/Customer/CustomerMessageHistory/Validator.php enforced required/non-empty, positive-integer userId, and mb_strlen length caps (#502/#506), but never checked membership in the MessageChannel enum that has nominally governed the column since #468 — so a backend client could write 'sms', 'PUSH', or an arbitrary category string straight into the channel column, bypassing a contract the enum's own OpenAPI documentation (#501) already advertised.
    • The fix. validateForCreate() and validateForUpdate() each gain one elseif (MessageChannel::tryFrom($data->messageType) === null), ordered after the existing required/non-empty and length checks — so an overlength value still reports the length error, not the enum error. On update the check sits inside the existing $data->messageType !== null presence gate, so a partial update omitting messageType is unaffected. A new private messageTypeChannelError() derives the message from array_column(MessageChannel::cases(), 'value') rather than a hardcoded list, so the error text widens automatically the day #496 adds a third case.
    • Strict casing, deliberately. tryFrom() runs on the raw value — only 'EMAIL' and 'SMS' pass; 'email' is rejected. All three legacy writers (Adv_mailer, sms_helper, shopmodule_helper) emit the uppercase enum value, and patches/BackfillCustomerMessageHistoryMessageType.php already normalized the column to uppercase, so accepting mixed case would re-open exactly what that backfill closed.
    • No read-path or contract change. ListRequest's messageType filter is untouched — historical rows stay queryable regardless of what they hold. No OpenAPI diff: WriteData.php already documented the EMAIL/SMS set (#501); this release only makes the write path enforce what the contract already advertised.
    • Tests: ValidatorTest.php gained 7 cases (create accepts 'SMS', rejects lowercase/'PUSH'/an arbitrary category string, error names both channels; update accepts 'SMS', rejects lowercase, allows messageType omitted); one existing test was corrected rather than relaxed (test_validate_for_create_allows_max_length_boundaries → ..._type_boundary, since no valid channel value can approach the 50-char cap, so it's now reachable only as a rejection). Integration/.../ServiceTest.php had 5 write-path fixtures de-inverted (channel now in messageType, category in type — the #501 field-swap correction) plus 4 paired assertions updated; the direct-insert seeds that bypass the Validator were deliberately left alone.
  • [4.121.0] test(session): cover the MY_Session route-exclusion guard and the bot-detector fail-open branch (Advisable-com/ecommercen#245)

    • What was missing. MY_Session::isRouteExcluded() (application/libraries/Session/MY_Session.php:71-87) and normalizeRouteElements() (:89-94) had zero test coverage. Both guards sit in the critical path of every single HTTP request that reaches PHP — MY_Session::__construct() short-circuits before parent::__construct() when either returns true (MY_Session.php:14-16), so a regression here would silently either create sessions for crawlers or skip sessions for real users.
    • What was already covered. tests/Legacy/Session/MySessionBotDetectionTest.php already carried 8 passing tests for isBotSession(), added 2026-06-18 during the #285 cache-wiring work. The issue body's claim that this file was untested was stale — this delivery does not duplicate that coverage.
    • What was added. 9 new tests across 2 files, 402 added lines:
      • tests/Legacy/Session/MySessionRouteExclusionTest.php (new) — 8 tests for isRouteExcluded() / normalizeRouteElements().
      • tests/Legacy/Session/MySessionBotDetectionTest.php (+1 test) — test_parser_failure_fails_open_and_returns_false, covering the catch (\Throwable) fail-open at MY_Session.php:43-46.
    • Why the fail-open test is not a duplicate of the existing test 4. The pre-existing test_cache_unavailable_fallback_does_not_throw_and_returns_false exercises botDetectorCache()'s own catch (MY_Session.php:62-68), which returns null and lets the parse succeed uncached — the production catch inside isBotSession() (:43-46) was never reached. The new test reaches it by overriding botDetectorCache() to return a \DeviceDetector\Cache\CacheInterface whose fetch() throws, and asserts a Googlebot UA (normally true) comes back false — proving fail-open rather than a coincidental browser-UA false.
    • Test harness notes. Route stubbing goes through the swappable GetInstanceRegistry in tests/bootstrap.php; a concrete instance set via GetInstanceRegistry::set() takes precedence over the real-CI factory registered at tests/bootstrap_ci.php:79, and tearDown() restores it with set(null). MY_Session is never constructed normally (its constructor boots real session machinery) — tests use an anonymous subclass whose constructor does not call parent::__construct().
    • Verification. vendor/bin/phpunit --testsuite Legacy --filter 'MySession' → OK, 17 tests / 31 assertions. Full --testsuite Legacy → OK, 797 tests / 1977 assertions, 8 skipped (all pre-existing: Windows platform guards plus two documented issue-#34 placeholders).
  • [4.121.0] refactor(mail): remove the orphan ask_us_email template and its preview entry (Advisable-com/ecommercen#97)

    • Why. ask_us_email had no dispatch path anywhere in the platform: no layout key in application/config/emailViews.json, no EMAIL_SUBJECTS registry key, and no method in application/models/Adv_mailer.php calling it. Its only code reference was one entry in the hard-coded $email_views preview list in ecommercen/settings/controllers/AdvEmailViewer.php::index(). Its content was a strict subset of contact_email.php (name/email/message vs. surname/name/tel/email/message), and its widgets.contact.* lang keys stay in use by contact_email.php, so no language file changed.
    • What was removed — three edits, landed together:
      1. Deleted application/views/main/mail/ask_us_email.php.
      2. Deleted application/views/default/mail/ask_us_email.php (the default/mail/ copy was doubly dead — that directory is unreachable at runtime; see below).
      3. Removed the 'ask_us_email', entry (the first element) from AdvEmailViewer::$email_views in ecommercen/settings/controllers/AdvEmailViewer.php::index() — the list drops from 21 entries to 20, and its line range shifts from 166-188 to 166-187.
    • Why both halves had to land together. The admin preview page (application/views/admin/settings/email_views.php) loops $email_views and calls $this->load->view($this->client_views . '/mail/' . $email_view, $data, true) for every entry. Deleting the template files while leaving the list entry in place would have fataled the entire admin email-preview page with CodeIgniter's "Unable to load the requested file" — so the array edit and the file deletions are one atomic change, not two.
    • No upstream behaviour change. Nothing dispatched ask_us_email before this change, so no live mail flow is affected — this is dead-code removal, not a functional change.
    • AdvEmailViewer::sampleEmailData() was deliberately left untouched — its full_name, email, and message keys were consumed only by this template among main-repo mail views, but they remain so a fork that re-adds the template still gets a working preview.
  • [4.121.0] fix(orders/admin): stop the order edit form from rejecting the transporter it already shows as selected

    • Bug. assets/admin/js/order/AdminOrderTransporters.vue opens mounted() with await this.initTransporters(). That runs while nothing is selected yet, so it publishes window.orderDataStore.transporterData with an empty id. Page-ready in application/views/admin/footer_js.php then fires notifyTransporters (through updateVueTransporterAttributes()) and product-item-added, but both listeners are only registered after that await, so neither event has a subscriber yet and both are dropped. When the request finally resolves, selectedTransporter is assigned from the clonedTransporter prop — the &lt;select> renders the order's carrier correctly — but setWindowTransporterData() is never called again, so the store keeps the empty id.
    • Symptom. Opening an existing order at orders_admin/edit/&lt;id> and pressing Επεξεργασία without touching anything failed the submit-time guard in footer_js.php (admin.validation.transporter.required — "Το πεδίο Μεταφορική είναι υποχρεωτικό."), even though the dropdown visibly showed the carrier. The submit was blocked in the browser, so it never reached Adv_orders_admin::validation() — its transport_id rule was never the one complaining. The same stale store also left transferCost and deliveryCost at 0, so the displayed totals under-reported shipping. Both symptoms cleared as soon as the operator touched the transporter dropdown or any product line, since each of those re-triggers calculateCosts() — which is why the bug reads as intermittent.
    • Fix. Once the bus listeners are bound and selectedTransporter has been set from clonedTransporter, run calculateCosts() a single time when a transporter is already selected. That re-publishes transporterData with the real id and the real transfer/delivery costs before the operator can submit.
    • Scope. Order edit and repeat only — the two views that pass cloned-transporter (application/views/admin/orders/edit.php, application/views/admin/orders/repeat.php). Order create is unaffected: clonedTransporter is empty there, the guard short-circuits, no extra request is issued, and selection still flows through the existing onChangeTransporter() path. Client-side only; no PHP, no API and no contract change.
  • [4.121.0] fix(blog/admin): stop the article edit form from wiping the selected Builder block on every save

    • Bug. application/views/admin/blog/blogs/update.php built the Builder dropdown's field name with a malformed interpolation — "builder_block_id_$langAbbr}" instead of "builder_block_id_{$langAbbr}". PHP terminates the simple $langAbbr variable at the }, so the brace was emitted literally and the select rendered as name="builder_block_id_el}". Adv_blog_admin::getMuiUpdatePost() reads builder_block_id_{$langAbbr} (builder_block_id_el), found nothing in the POST, and fell through its ?: null, so Adv_base_model::updateOrInsertMui() wrote blog_mui.builder_block_id = NULL on every save from the article edit screen.
    • Symptom. A Builder block could only ever be attached at article creation (create.php always had the correct name) and was silently detached again by the first edit-save — the article reverted to "no Builder". Nothing about it was publish-specific: is_published only ever reaches the master table, but toggling Published is a save like any other, so the loss was commonly first noticed at publish time.
    • Fix. Restore the {} around the interpolated variable on both the form_dropdown() name argument and the set_value() key. The set_value() lookup key now matches the rendered field name too, so a re-displayed form after a validation failure keeps the operator's pending selection instead of falling back to the stored value.
    • Scope. View-only, one line. Adv_blog_admin and Adv_blog_model were always correct and are untouched. Introduced in 6fb7ef5837 (refactor(blog): modernize blog administration logic and view templates) when $l_abbr was renamed to $langAbbr; present on master and develop since, so every fork carrying that commit is affected.

Notes

  • [4.121.0] Why the gift-card leg is filtered on save too, honestly stated. Unlike PAYWAY_EXTERNAL, nothing gates order placement on the PAYWAY_GIFT_CARDS_EXTERNAL key — PlaceOrderService::externalPayways() reads PAYWAY_EXTERNAL only. So this filter does not close an orphaned-order path the way the payments-panel filter does; it exists to keep the saved selection consistent with the panel's own available pool, nothing more.

  • [4.121.0] Behavioural change — out-of-pool saved selections are removed on save, not hidden on display. A METHODS.PAYWAY_EXTERNAL selection containing a key outside getExternalPayWays() still appears in the selected box, correctly labelled, until the next save of the payment-settings form removes it — it is not hidden and not silently dropped from view; it is removed on save (of either external panel's field, since a save re-derives both selections). No live deployment is affected today — METHODS.PAYWAY_EXTERNAL is empty everywhere, since #672 (which introduced it) has not shipped in a release yet — but state the behaviour for the record: a stale or forged selection survives on screen for one round-trip, then does not survive a save.

  • [4.121.0] Fixed a display bug found while narrowing these panels. The selected-side label lookup on both external panels used the narrowed pool (getExternalPayWays() / getExternalGiftCardPayWays()) instead of allPayWays(), so a key saved before the narrowing (e.g. a piraeus value already in the DB) resolved no label. array_flip() on the registry list yields integer positions, not names, so form_multiselect rendered the option text as a bare digit — &lt;option value="piraeus">0&lt;/option> instead of the payway's actual name. Fixed by resolving the selected-side labels against allPayWays() on both external panels, independently of which pool narrows the available side.

  • [4.121.0] piraeus is intentionally not in the pool. #674 (same unreleased batch) added working per-channel Piraeus POS credentials (PIRAEUSBANK_EXTERNAL, PIRAEUSBANK_GIFTCARDS_EXTERNAL) and wired registerPiraeus() to read them — those credentials remain valid and in place. piraeus simply is not selectable on either external panel until it has been verified end-to-end through an external frontend, per the capability-list rule above. A deployment expecting REST/headless Piraeus on a nuxt/velora/mobile channel will not get it from this pool alone; onboarding Piraeus on an external channel needs both #674's credentials configured and this pool updated to include piraeus once verified.

  • [4.121.0] Check for overrides: ecommercen/settings/controllers/Adv_settings.php::payment_settings() — a client fork overriding this method will not pick up the display narrowing on either external panel (external_payway_methods_default, externalGiftCardPaywaysDefault), the corrected allPayWays()-based label lookup for the selected side, or either of the two save-time filters (PAYWAY_EXTERNAL against getExternalPayWays(), PAYWAY_GIFT_CARDS_EXTERNAL against the new getExternalGiftCardPayWays()) — and will keep offering/accepting all 20 payways on its external panels.

  • [4.121.0] UI Update: application/views/admin/settings/payment_settings.php — a fork carrying its own wholesale copy of this view (already established as a real pattern by #553 and #672's own notes) will not pick up the removal of the getVivaEnabledPaymentMethods() union on the external payments panel's available side, and will keep offering viva sub-method keys that cannot function on an external channel. The panel's &lt;select> markup itself is otherwise unchanged — only the PHP expression feeding its options.

  • [4.121.0] Check for overrides:

    1. Behavioural break for anyone already taking Piraeus through REST/headless. The moment this lands, registerPiraeus() reads PIRAEUSBANK_EXTERNAL, which is empty on every existing deployment — nothing is inherited from PIRAEUSBANK. The piraeus adapter simply stops being registered until an admin fills in the new credential block. Because #673 made /rest/checkout/payment-methods fail closed, the payway then silently disappears from the payment list and from placement — no error, just a missing payway. This is the single most likely support ticket to come out of this change.
    2. application/views/admin/settings/payment_settings.php is client-overridable, and the consequence on an un-reconciled fork is worse than missing inputs. A fork carrying its own copy of this view will not get the new credential blocks on merge, so it renders no inputs for the two new POS terminals. But ecommercen/settings/controllers/Adv_settings.php:1114-1127 saves PIRAEUSBANK_EXTERNAL unconditionally on every payment-settings save — there is no guard on that write. So every save on the fork's un-reconciled form posts empty values for those fields and nulls the entire external credential group, not merely leaves it undocumented — the external POS is impossible to configure on that fork until the view is reconciled, even by an admin editing the database directly, since the next save wipes it again. This is consistent with the file's established behaviour rather than a new defect: the gift-card external block is guarded by GIFT_CARDS.ENABLED (skipped on save when gift cards are off), but the unconditional save for the checkout-side blocks mirrors the pre-existing checkout block's own pattern — this addition follows the same unconditional-save convention the file already used for its non-gift-card blocks. Treat this as a reconciliation duty for the fork maintainer, not an upstream defect. Note that issues #553 and #672 both recently changed this same file, so a fork reconciling it has several changes to fold in at once, not just this one.
    3. The two new helper functions are function_exists-guarded (getPiraeusExternalBankSettings, getPiraeusGiftCardExternalBankSettings in ecommercen/helpers/registry_helper.php) — a fork that has already defined a same-named helper of its own would silently win over the upstream one. No such name exists upstream today, so this is a latent risk rather than a live one.
  • [4.121.0] Merchant onboarding, not purely a code deploy: Piraeus must register the callback URL against the new POS terminal bank-side, and the credentials themselves are provisioned per terminal by Piraeus. Enabling the external/mobile channel therefore requires a merchant-side onboarding step in addition to filling in the new admin credential block.

  • [4.121.0] Check for overrides:

    • src/Domains/Checkout/PlaceOrderService.php — a fork that overrides this service will not get the payway gate, so its external frontends would keep accepting a deselected payway with no refusal.
    • REST contract: any fork or client reading GET /rest/checkout/payment-methods must handle an empty paymentMethods[], and any client posting to POST /rest/checkout/place-order must handle a 422 carrying error.code = payway_not_available.
  • [4.121.0] src/Domains/Checkout/container.php gained ->arg('$registry', null) on PlaceOrderService (the same lazy \Registry seam GiftPackagingResolver already used) — this makes explicit what autowiring already infers, since the constructor's ?Registry $registry = null is nullable, optional, and last. A fork that autowires PlaceOrderService (this codebase's convention) needs no change; only a fork that disables autowiring and hand-builds a positional argument list would need to add the argument.

  • [4.121.0] Check for overrides: both edited views are client-overridable and a fork carrying its own copy of either will silently miss the new panels on merge — no error, just no external-frontend payway UI in that fork's admin, while the backend half above still happily persists METHODS.PAYWAY_EXTERNAL / METHODS.PAYWAY_GIFT_CARDS_EXTERNAL if anything ever posts to those fields. Issue #553 already established that at least one client fork ships its own payment_settings.php override, so this is not hypothetical.

    • application/views/admin/settings/payment_settings.php
    • application/views/admin/footer_js.php
    • New public surface a fork must reconcile when merging:
      • Two POST fields: external_payway_methods[], external_gift_card_payways[]
      • Two registry keys: METHODS.PAYWAY_EXTERNAL, METHODS.PAYWAY_GIFT_CARDS_EXTERNAL
      • Sixteen new element ids — eight per panel: multiselect4, multiselect4_to, multiselect4_{rightAll,rightSelected,leftSelected,leftAll,move_up,move_down}, and the same set for multiselect5. The crlcu multiselect plugin binds purely by id, so a fork that already added its own extra panel using multiselect4/multiselect5 ids would collide with this one — silently breaking a panel with no error.
      • The two new footer_js.php inits must carry sort: false, or the saved payway order gets re-sorted in the UI on every move.
  • [4.121.0] UI Update: admin → product categories. Deleting a category now succeeds when it has no children and no products, matching the rule the delete action has always claimed to enforce. A category with children, or with products, is still refused. Nothing else about the screen changes.

  • [4.121.0] Check for overrides: this isn't a removed/renamed/re-signatured method — hasProducts() keeps its signature; only its body became correct — but the same shadowing risk applies from the other direction, so check anyway. hasProducts(), canDeleteRecord(), hasChildren() and delete_record() are all public on the upstream Adv_product_category_model, which every fork reaches through the thin Product_category_model extends Adv_product_category_model subclass at application/modules/eshop/models/Product_category_model.php. A fork that has not touched this inherits the fix automatically — no action needed. A fork that independently patched hasProducts() in its own subclass now carries a redundant override that shadows the fixed base method, and should remove it. Verified, not hypothetical: the Evripidis fork (clients/evripidis) overrides hasProducts() at application/modules/eshop/models/Product_category_model.php:216, and that override is byte-identical to this fix — ->where('category_id', $id) and all. Dropping it is risk-free, not merely advisable: there is no behaviour to regress. The override's own docblock already says "Reported upstream as Advisable-com/ecommercen#669; drop this override once the fix lands upstream."

  • [4.121.0] Check for overrides: Adv_home::getStreamVideos() (ecommercen/eshop/controllers/Adv_home.php:404) — a fork that copied this method carries the quoted 'default' arm; its homepage still 500s on any non-BUNNY/unset provider.

  • [4.121.0] Check for overrides: Adv_reels::createProvider() (ecommercen/eshop/controllers/Adv_reels.php:255) — signature frozen at protected ... : ?object. A fork returning a non-null fallback object, or one that also overrode __construct(), still passes null into VideoManager.

  • [4.121.0] Check for overrides: Adv_reels::__construct() (ecommercen/eshop/controllers/Adv_reels.php:29) — a fork overriding the constructor and keeping new VideoManager($this->createProvider()) stays broken on both routes even with the showcase off.

  • [4.121.0] Check for overrides: Adv_reels::buildPaginatedVideos() (ecommercen/eshop/controllers/Adv_reels.php:81) — a fork override keeping the single-condition bail dereferences a null $videoManager at getUrls().

  • [4.121.0] Check for overrides: AdvUploadVideoToStream::executeCommand() (ecommercen/job/libraries/AdvUploadVideoToStream.php:31) — a quoted-arm copy throws instead of no-op-ing.

  • [4.121.0] Check for overrides: AdvDeleteVideoToStream::executeCommand() (ecommercen/job/libraries/AdvDeleteVideoToStream.php:31) — same quoted-arm risk, throwing job.

  • [4.121.0] Check for overrides: AdvCheckVideoStatusStream::executeCommand() (ecommercen/job/libraries/AdvCheckVideoStatusStream.php:31) — same quoted-arm risk, throwing job.

  • [4.121.0] Check for overrides: Adv_reels::$videoManager changed type from VideoManager to ?VideoManager — a fork subclass reading the property unconditionally now needs a null check.

  • [4.121.0] Check for overrides: this delivery widens which forks the #641 invariant fires on, in a shape no previous delivery could reach, and the failure it produces is not test noise. A fork that copied a whole src/Domains/**/Repository/ directory for a registered target — today Product\Product, Cms\Blog\Article or Cms\Page — and rewrote only the namespace root now has that copy's relations inside the invariant. Those relations were previously skipped outright, so a fork that was green before this can turn red on it without the fork changing at all. When it does, the red is a live leak, not a false positive: for Cms\Page a pre-#639 copy means unpublished CMS pages reachable by guests on GET /rest/cms/page, with ?with=children.translations returning the categories_mui body, meta fields and slug beneath an unpublished node.

  • [4.121.0] How to fix it: re-sync the flagged configurator against the current src/ version — take the scope class named in the failure message as a constructor dependency and hand its relationScope() to each affected Relation's visibilityScope: argument as a named argument ($visibilityScope is Relation::__construct's tenth parameter, so a positional argument is not a safe substitute). Every hop needs its own declaration; a visibilityScope does not cascade across relation definitions. Do not add the relation to visibilityScopeExemptions() to make the suite pass — that list is only for relations with no client-reachable path, each carrying its own stated justification, and the paths above are guest paths. And do not revert the target lookup to a raw isset() — that would silently re-open every namespace-local target this delivery fixed, for every fork, not just yours.

  • [4.121.0] Supersedes a bound recorded by #658. #658's fragment records that a fork copying the whole src/Domains/Cms/Page/Repository/ directory has its page relations outside the invariant, and asks fork owners to verify those relations by hand "until #661 lands". #661 has landed: those relations are now checked, and the manual verification that note asks for is no longer needed. #658's fragment is left as written. Both fragments are still in unreleased/ and assemble into the same version, which is the case this family has already settled: #655 left #653's fragment untouched from inside that same unreleased batch, and the whole visibility-scope family is still unreleased. A fragment is the point-in-time record of one delivery — so read the two together, this entry last.

  • [4.121.0] Check for overrides: a fork that has copied or overridden tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php itself will not pick up the normalised target lookup, keeps the blind spot for every namespace-local copy it holds, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.

  • [4.121.0] No php migrator.php migrate, no composer install, no frontend build needed for this change.

  • [4.121.0] Check for overrides: this delivery widens which forks the #641 invariant fires on, and the failure it produces is not test noise. RelationConfigurationTest's walk covers custom/Domains when that directory exists, so a fork carrying its own copy of Cms\Blog\Article's RepositoryConfigurator — aliased over the upstream class in custom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant for its tags hop too. A pre-#640 snapshot constructs fine, autowires fine, boots the container fine, and returns visibilityScope === null. When this guard turns red on a fork it means that fork is serving INACTIVE BLOG TAGS to unauthenticated callers. Cms\Blog\Article.tags embeds on the guest-readable GET /rest/cms/blog/article, so ?with=tags reaches them directly on every row of an index() page, and any longer chain reaching a tag reaches them at depth — which a per-endpoint relations allow-list cannot cover, since RelationFilterMiddleware inspects only the first ?with= hop. #624 scoped the cms/blog/tag endpoint and denied ?sort=tags.active; neither reaches a relation — a forced filter scopes only the endpoint it is registered on, and denying a sort removes the enumeration primitive rather than the disclosure.

  • [4.121.0] How to fix it: re-sync the fork's copy of Cms\Blog\Article\Repository\RepositoryConfigurator against the current src/ version — inject BlogTagVisibilityScope into the configurator's constructor and hand its relationScope() to tags as a named visibilityScope: argument ($visibilityScope is Relation::__construct's tenth parameter, so a positional argument is not a safe substitute). The scope class is stateless — one WHERE clause, no tree walk, no query of its own, no DB dependency to inject — because tags are a flat vocabulary with no parent/child column. Do not add the hop to visibilityScopeExemptions() to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and /rest/cms/blog/article is a guest path. Exempting it would re-open the hole and silence the alarm along with it.

  • [4.121.0] Check for overrides — this configurator now carries THREE injected scopes, and a re-sync must restore all three. Cms\Blog\Article's constructor takes ProductVisibilityScope, BlogCategoryVisibilityScope and BlogTagVisibilityScope, and its comments hop additionally uses the static CommentVisibilityScope. Every one of those hops is now inside the invariant through a different registry entry, and they are different classes with different rules — products (#637), categories (#640), comments (#616) and tags (#640). A fork that restores only the hop its red test names will go red again on the next one. The articles-family hops are the most severe of the set: they leak the complete pre-publication body of every unpublished draft (blog.is_published is NOT NULL DEFAULT 0) in one unauthenticated request.

  • [4.121.0] Check for overrides: a fork that has copied or overridden tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php itself will not pick up the new registry entry, the coverage pin or the blog-tag failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy. The registry is now complete at seven entries, so a fork re-syncing this file gets the whole family at once rather than one entry per upstream release.

  • [4.121.0] Check for overrides: this delivery widens which forks the #641 invariant fires on, and the failure it produces is not test noise. RelationConfigurationTest's walk covers custom/Domains when that directory exists, so a fork carrying its own copy of Cms\Blog\Category's (or Cms\Blog\Article's) RepositoryConfigurator — aliased over the upstream class in custom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant. A pre-#640 snapshot constructs fine, autowires fine, boots the container fine, and returns visibilityScope === null. When this guard turns red on a fork it means that fork is serving INACTIVE BLOG CATEGORIES, and their blog_categories_mui bodies, to unauthenticated callers. rest_policies.php:227-234 gives BlogCategory index/show/item auth => 'guest', so ?with=children and ?with=parent reach the taxonomy directly on every row of an index() page, and ?with=children.translations hands back the blog_categories_mui rows (name, slug, meta, description) beneath an inactive node. ?with=children* additionally makes the recursive descent's cost attacker-chosen, because the recursion marker is stripped before matching. #624 scoped the cms/blog/category endpoint and denied ?sort=children.active; neither reaches a relation — a forced filter scopes only the endpoint it is registered on, and denying a sort removes the enumeration primitive rather than the disclosure.

  • [4.121.0] How to fix it: re-sync the fork's copy of Cms\Blog\Category\Repository\RepositoryConfigurator against the current src/ version — inject BlogCategoryVisibilityScope into the configurator's constructor and hand its relationScope() to each of children and parent as a named visibilityScope: argument ($visibilityScope is Relation::__construct's tenth parameter, so a positional argument is not a safe substitute). Both hops need their own declaration; a visibilityScope does not cascade across relation definitions, and the recursive descent then re-applies it at every depth. The scope class is stateless — no DB dependency, no tree walk, nothing to memoise — which is exactly what the #640 flatness measurement bought. Do not add either hop to visibilityScopeExemptions() to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and /rest/cms/blog/category is a guest path. Exempting it would re-open the hole and silence the alarm along with it.

  • [4.121.0] Check for overrides — the fork's articles hop is a separate and more severe exposure on the same configurator. BlogCategory.articles is scoped by ArticleVisibilityScope, not by this entry's scope, and it is already inside the invariant via the Article registry entry (#655). A fork whose copy lost that argument leaks the complete pre-publication body of every unpublished draft (blog.is_published is NOT NULL DEFAULT 0) in one unauthenticated request. Re-syncing the configurator must restore both injected scopes — the module's constructor takes two, and they are different classes.

  • [4.121.0] Check for overrides: a fork that has copied or overridden tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php itself will not pick up the new registry entry, the coverage pin or the blog-category failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.

  • [4.121.0] Check for overrides: this delivery widens which forks the #641 invariant fires on, and the failure it produces is not test noise. RelationConfigurationTest's walk covers custom/Domains when that directory exists, so a fork carrying its own copy of Cms\Page's RepositoryConfigurator — aliased over the upstream class in custom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant provided the copy still names the upstream Advisable\Domains\Cms\Page\Repository\Repository as the relation target. A pre-#639 snapshot constructs fine, autowires fine, boots the container fine, and returns visibilityScope === null. When this guard turns red on a fork it means that fork is a live leak: unpublished CMS pages reachable by guests on GET /rest/cms/page, ?with=children.translations returning the categories_mui body, meta fields and slug beneath an unpublished node, and ?with=children* making the walk's cost attacker-chosen because WithParser does not strip the recursion marker.

  • [4.121.0] How to fix it: re-sync the fork's copy of Cms\Page\Repository\RepositoryConfigurator against the current src/ version — take PageVisibilityScope as a constructor dependency and hand its relationScope() to each of the two Relations' visibilityScope: argument as a named argument ($visibilityScope is Relation::__construct's tenth parameter, so a positional argument is not a safe substitute). Both hops need their own declaration; a visibilityScope does not cascade across relation definitions. Do not add either relation to visibilityScopeExemptions() to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and /rest/cms/page is a guest path. Exempting it would re-open the hole and silence the alarm along with it.

  • [4.121.0] Check for overrides — a bound worth knowing before trusting a green run. A fork that copies the whole src/Domains/Cms/Page/Repository/ directory, rather than the configurator alone, makes the configurator's bare Repository::class resolve into the fork's own namespace. Those relations are then not checked by this invariant at all (Advisable-com/ecommercen#661), and the blind floor stays quiet because upstream's own two relations still match. A green suite on such a fork is not evidence the fork's page relations are scoped — verify them by reading the copied configurator until #661 lands.

  • [4.121.0] Check for overrides: a fork that has copied or overridden tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php itself will not pick up the new registry entry, the coverage pin or the Page failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.

  • [4.121.0] Check for overrides: this delivery widens which forks the #641 invariant fires on, and the failure it produces is not test noise. RelationConfigurationTest's walk covers custom/Domains when that directory exists, so a fork carrying its own copy of Cms\Blog\Comment's (or Cms\Blog\Article's) RepositoryConfigurator — aliased over the upstream class in custom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant. A pre-#616 snapshot constructs fine, autowires fine, boots the container fine, and returns visibilityScope === null. When this guard turns red on a fork it means that fork is leaking its MODERATION QUEUE. CommentVisibilityScope narrows to blog_comments.status = 'approved', so the stale copy serves the pending and rejected rows — the spam, abuse and not-yet-vetted backlog that exists precisely because nobody has reviewed it — to unauthenticated callers. Both doors are guest-open: rest_policies.php:254-259 gives BlogComment index/show/item auth => 'guest', so ?with=children and ?with=parent reach it directly on every row of an index() page, and :218-224 does the same for BlogArticle, so ?with=comments.children reaches it one hop away. ?with=children* additionally makes the recursive descent's cost attacker-chosen, because the recursion marker is stripped before matching.

  • [4.121.0] How to fix it: re-sync the fork's copy of Cms\Blog\Comment\Repository\RepositoryConfigurator against the current src/ version — hand CommentVisibilityScope::relationScope() to each of parent and children as a named visibilityScope: argument ($visibilityScope is Relation::__construct's tenth parameter, so a positional argument is not a safe substitute). Both hops need their own declaration; a visibilityScope does not cascade across relation definitions, and the recursive descent then re-applies it at every depth via OneToManyLoader::loadRecursiveChildren(). Unlike every other scope in the tree this one needs no constructor injection at all — it is a static call, so the configurator stays dependency-free. Do not add either hop to visibilityScopeExemptions() to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and /rest/cms/blog/comment is a guest path. Exempting it would re-open the hole and silence the alarm along with it.

  • [4.121.0] Check for overrides — do not "simplify" the status rule into $scope. Article.comments is the one relation in the tree where both slots are populated, and that pairing is the point rather than an accident (#616): scope is parent_comment_id IS NULL, an always-on structural invariant so nested replies are not duplicated at the top level, while visibilityScope is the approved-only visibility rule that backend callers are exempt from through Relation::VISIBILITY_EXEMPT_ALL — because moderating the pending and rejected rows is the entire admin use case. Folding the status rule into $scope would blind the admin blog view; folding the parent_comment_id rule into $visibilityScope would let an admin read the reply tree twice.

  • [4.121.0] Check for overrides: a fork that has copied or overridden tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php itself will not pick up the new registry entry, the coverage pin or the blog-comment failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.

  • [4.121.0] Check for overrides: this delivery widens which forks the #641 invariant fires on, and the failure it produces is not test noise. RelationConfigurationTest's walk covers custom/Domains when that directory exists, so a fork carrying its own copy of Product\Category's RepositoryConfigurator — aliased over the upstream class in custom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant. A pre-#625 snapshot constructs fine, autowires fine, boots the container fine, and returns visibilityScope === null. When this guard turns red on a fork it means that fork is a live leak: on the guest-readable GET /rest/product/category, ?with=children.translations returns the shop_product_category_mui rows — name, slug, meta and fulltext — of categories the caller must not be able to navigate to, on every row of an index() page, so the hidden set is bulk-enumerable in one unauthenticated request; ?with=relativeCategories.translations is the same disclosure reached sideways; and ?with=children* makes the walk's cost attacker-chosen. The affected population is not marginal — categories hidden by the ancestor-chain rule but not by their own flag are 1.2% of wecare, 3.7% of smile and 42% of pharm16.

  • [4.121.0] How to fix it: re-sync the fork's copy of Product\Category\Repository\RepositoryConfigurator against the current src/ version — take CategoryVisibilityScope as a constructor dependency and hand its relationScope() to each of parent, children and relativeCategories as a named visibilityScope: argument ($visibilityScope is Relation::__construct's tenth parameter, so a positional argument is not a safe substitute). All three hops need their own declaration; a visibilityScope does not cascade across relation definitions. Do not add any of them to visibilityScopeExemptions() to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and /rest/product/category is a guest path. Exempting it would re-open the hole and silence the alarm along with it.

  • [4.121.0] Check for overrides — tagGroups must stay unscoped. A fork "completing the set" by attaching CategoryVisibilityScope to tagGroups would not merely be wrong, it would be a hard SQL error: that relation targets shop_product_group_tags, which carries no visibility flag, and the closure names shop_product_category.id, a table absent from the relation's FROM. The invariant does not ask for it — tagGroups resolves to Product\Tag\Category\Repository\Repository, which is not a registry key.

  • [4.121.0] Check for overrides: a fork that has copied or overridden tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php itself will not pick up the new registry entry, the coverage pin or the product-category failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.

  • [4.121.0] Check for overrides: this delivery widens which forks the #641 invariant fires on, and the failure it produces is not test noise. RelationConfigurationTest's walk covers custom/Domains when that directory exists, so a fork carrying its own copy of Cms\Blog\Author, Cms\Blog\Category, Product\Category or Product\Product's RepositoryConfigurator — aliased over the upstream class in custom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant. A pre-#653/#640/#625 snapshot constructs fine, autowires fine, boots the container fine, and returns visibilityScope === null. When this guard turns red on a fork it means that fork is a live leak: unpublished blog articles reachable by guests, ?with=articles.translations returning the complete pre-publication body of every draft (blog.is_published is NOT NULL DEFAULT 0), bulk-enumerable in one unauthenticated request.

  • [4.121.0] How to fix it: re-sync the fork's copy of the flagged configurator against the current src/ version — take ArticleVisibilityScope as a constructor dependency and hand its relationScope() to the Relation's visibilityScope: argument as a named argument ($visibilityScope is Relation::__construct's tenth parameter, so a positional argument is not a safe substitute). Do not add the relation to visibilityScopeExemptions() to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and ?with=articles.translations is a guest path. Exempting it would re-open the hole and silence the alarm along with it.

  • [4.121.0] Check for overrides: a fork that has copied or overridden tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php itself will not pick up the new registry entry or the coverage pin, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.

  • [4.121.0] Check for overrides — a fork keeping its own copy of either configurator silently loses the scope. Such a copy is autowired by FQCN, constructs fine on the old argument list, returns visibilityScope === null, and boots green with a passing check-container — so it keeps serving unpublished article bodies to guests with no error and no warning. This is the same shape #637/#639/#640 each warned about.

    • Cms\Blog\Author's configurator previously had no constructor at all — it gains its first (0 → 1). Product\Product's already had one (taking CategoryVisibilityScope since #588) and goes 1 → 2. A fork carrying either copy needs the dependency added.
    • If a fork must keep its own copy, it has to pass visibilityScope: as a named argument. It is Relation::__construct's tenth parameter, and for Product.articles — which is MANY_TO_MANY — a positional tenth lands silently in $pivotTable, which is data corruption, not a no-op.
    • Preferred over patching the fork's copy: drop the forked configurator and rebind ArticleVisibilityScope from the fork's own module container instead (Symfony DI here is last-wins by module load order), so the fork keeps inheriting every relation upstream adds later rather than freezing a copy.
  • [4.121.0] UI Update: both are collection embeds, so a hidden article simply drops out of the array — the list is shorter and may now be empty; no key is removed and no error is raised. The owning list itself is not filtered and pagination counts do not move.

  • [4.121.0] Check for overrides: three surfaces a fork may carry its own copy of, and one of them fails silently.

    • src/Rest/Support/Controllers/HandlesRestfulActions.php — buildListRequest() (protected) now clamps before returning, and two new protected seams sit beside it: maxPageSize(): int and the constants MAX_PAGE_SIZE_CONFIG_KEY / DEFAULT_MAX_PAGE_SIZE. No signature changed and nothing was removed, so an existing override still compiles — but a fork that overrides buildListRequest(), or whose controller builds its own ListRequest from the raw (new $this->listRequestClass())->generate($this->input) construction, silently keeps the unbounded behaviour, with no error and a green boot. That is the one to grep for. A fork wanting a different ceiling should override maxPageSize() (it must return a positive int) rather than the build path.
    • application/config/app.php — new key rest_max_page_size (default 1000). This file is client-owned; a fork that does not merge the line still gets the 1000 ceiling from the code-level fallback, so nothing breaks if it is missed — but the ceiling is not tunable for that shop until the key is added.
    • src/Rest/Admin/Controllers/Role.php — index() no longer builds its ListRequest directly. A fork overriding Role::index() should adopt $this->buildListRequest() for the same reason.
  • [4.121.0] UI Update: REST contract change for storefront consumers — behavioural, not structural. No route, response field, schema or status code changed. ?limit above the ceiling is now clamped for guest and customer callers: the request still returns HTTP 200 with a normal body and no error of any kind to signal that it was reduced. A headless client that was passing a limit above 1000 (previously honoured literally) will now receive 1000 rows per request and must page through the result set — reading pagination.per_page, pagination.total_pages and pagination.has_next from the response rather than assuming its requested size was honoured, and never echoing its own requested limit back as the page size. pagination.per_page reports the clamped value, so it is the reliable way to detect that a clamp occurred. Clients at or below 1000 — which is every known storefront consumer, including the catalogue-facet reader and the one-shot reference-list readers — are unaffected, and a request that omits limit still gets 15 exactly as before. Backend and admin clients are exempt and need no change.

  • [4.121.0] Follow-up, deliberately not done here: the limit query parameter is documented per controller, not centrally — name: 'limit' appears in 109 files under src/Rest/. Only /rest/product/product's description was updated to state the ceiling and the clamping behaviour; of the remaining 108, 107 still read "Pagination limit" (one, Admin\Controllers\User.php, already carried different pre-existing wording — untouched here either way). Editing all of them was out of scope for this change and wants its own pass — or, better, a shared OpenAPI parameter component so the description has one home.

  • [4.121.0] No DB migration, no schema change, no route change, no language-key change. The OpenAPI annotation change above is source-only — public/openapi*.json and public/api-versions.json regenerate at the release cut, not here. A rest_api_versions.php entry is recorded under the per-task cadence, framed as a storefront-breaking clamp with the client action spelled out.

  • [4.121.0] Check for overrides: t() (application/core/MY_Lang.php) — signature unchanged (t(string $line, array $data = []): string). A fork that overrides t() keeps its own unguarded vsprintf() and stays vulnerable to this class of fatal; it should delegate to AdvLangFormatter::format() or replicate the guard.

  • [4.121.0] Check for overrides: ExternalLang::line() (ecommercen/libraries/ExternalLang.php) — signature unchanged (line(string $line, array $params = []): string). Same risk as t() for a fork overriding it.

  • [4.121.0] Check for overrides: any forked adv_advisable_lang.php / adv_external_lang.php / adv_theme_lang.php (ecommercen/language/&lt;locale>/ and any client override) — a fork shipping its own copies of these files carries its own copy of these defects: the bare %, the placeholder-count typos, the missing gift-card SMS key. Forks should re-run the same check against their own language files.

  • [4.121.0] Check for overrides: Adv_products_admin.php price-update success message (ecommercen/eshop/controllers/Adv_products_admin.php:1734) — the call changed from sprintf(t('...'), $i) to t('...', [$i]). A fork overriding this method and keeping the old double-format shape stays broken.

  • [4.121.0] Check for overrides: AdvLangFormatter is new and deliberately non-final, with protected helper methods — a fork may extend it.

  • [4.121.0] giftCard.message and giftCard.messageInformCustomer were deliberately left in place despite having no call site upstream, because a fork may call them. They were not deleted.

  • [4.121.0] Check for overrides: Advisable\Domains\Support\Service\HandlesNotEmptyFilters is deleted. A fork that uses it in a Custom\ Service will fatal on a trait-not-found. Replace the use with Advisable\Domains\Support\Service\BuildsFilterSpecifications, which provides isNotEmptyFilter() unchanged and replaces handleNotEmptyFilter($filter, $this->repository) with notEmptyFilterSpecification($filter) — the $repository argument is gone, because all 111 upstream call sites passed $this->repository and the trait already holds it.

  • [4.121.0] Check for overrides: every domain Service's buildSpecifications() is gone from the Service classes and is now inherited from BuildsFilterSpecifications as a protected method. A fork carrying its own copy of buildSpecifications() breaks in one of two ways, and which one depends on the copy's visibility. In the previous release 110 of the 114 upstream copies were private, so a verbatim fork copy is private too:

    • A private copy in a class that EXTENDS an upstream Service is a HARD FATAL AT CLASS LOAD — PHP forbids narrowing an inherited protected method: Fatal error: Access level to Custom\...\Service::buildSpecifications() must be protected (as in class Advisable\...\Service) or weaker (verified on PHP 8.1.29). This is the likely shape, because the documented client-override pattern is a Custom\ subclass plus a DI alias. It fails loudly and immediately — nothing silent about it. Fix: widen the copy to protected, then delete it and override a seam instead.
    • A copy in a fork that re-declares the whole Service class and uses the trait itself is legal, silently wins over the trait's method, and boots green — no error, no warning, a passing composer run check-container, and nothing in any test suite notices. This is the drift case, and it is the dangerous one: the fork stops receiving every later upstream change to that loop, including any change to the WithRelations(..., $listRequest->visibilityExemptions) argument, which is what restores full visibility for a privileged (backend) reader while relation visibility scopes stay fail-closed for everyone else. A fork whose copy drifts there breaks relation visibility scoping invisibly.
    • Triage a fork with one command: grep -rnE "(private|protected|public) function buildSpecifications" custom/ — a private hit is the loud fatal above, a protected/public hit is the silent drift.
  • [4.121.0] Do not carry a copy of the loop in either shape. Override the narrowest seam that expresses the intent instead:

    • filterOperator(FilterRequest $filter): ?string — one filter type maps to a different SQL operator (≈3 lines; this is the seam that did not exist before).
    • filterSpecification(FilterRequest $filter): ?Specification — this domain needs a different specification class for some filters. Delegate the tail to $this->operatorFilterSpecification($filter), never parent:: — a trait method is not reachable through parent::.
    • sortSpecification(SortRequest $sort): ?Specification — same, for a root sort; delegate to $this->columnSortSpecification($sort).
    • additionalSpecifications(ListRequest $listRequest): array — extra specifications between the sorts and pagination (e.g. a conditional default sort).
  • [4.121.0] Check for overrides: the filter-type → operator mapping is now platform-wide and DI-aliasable. To replace it for a whole client, alias the interface from custom/Domains/container.php:

    php
    $services->set(\Custom\Domains\Support\Request\QueryListBuilder\FilterOperatorMap::class)->public();
    $services->alias(
        \Advisable\Domains\Support\Request\QueryListBuilder\FilterOperatorMapInterface::class,
        \Custom\Domains\Support\Request\QueryListBuilder\FilterOperatorMap::class
    )->public();

    A fork that only needs a different mapping for ONE domain should override that Service's filterOperator() rather than alias the interface platform-wide.

  • [4.121.0] Check for overrides: Cms\Builder\ListRequest and Cms\Page\ListRequest changed a declared filter type — title and bannerImage from Partial to Exact. A fork carrying its own copy of either ListRequest will start emitting LIKE on that key. Upstream, the Partial declaration was inert because the Service's filterOperator() override forced '='; that override is now gone, so a fork's retained Partial declaration reaches the shared FilterOperatorMap and correctly resolves to 'LIKE' — turning ?filter[title]=Hero into a substring search that also matches every title merely CONTAINING "Hero". It is a live REST behaviour change for that fork only, it is silent, and no test upstream can see it. Fix: change the copy's declaration to Exact (matching upstream), or, if the fork actually wants substring matching, keep Partial deliberately and record that as a client customization. Triage: grep -rn "builder_blocks.title\|categories.banner_image" custom/.

  • [4.121.0] Check for overrides: application/config/container/modules.php gains a new module, src/Domains/Support/Request/QueryListBuilder/container.php. A client fork that maintains its own copy of modules.php must add the same line, or FilterOperatorMapInterface will not resolve and every Service will fall back to the upstream mapping (correct behaviour, but a client alias would silently never apply).

  • [4.121.0] Check for overrides: none, verified rather than assumed. A fork carrying its own ListRequest.php copy is untouched by this change and keeps declaring vendorCode as Partial — but on an unmodified Product\Product\Service, the generic filter branch still applies '=' regardless of the declared type, so the fork's behaviour is identical before and after this fix.

  • [4.121.0] Why bother fixing a no-op: the shared FilterOperatorMap resolves FilterRequestType::Partial to a LIKE operator platform-wide — Product\Product is the one domain that deliberately overrides it to keep '=' for this column. As long as vendorCode stayed declared Partial, the obvious "make this consistent with its siblings" refactor to Product\Product\Service would not have produced a LIKE scan: the bound guard added for the one-sided comparisons is keyed on "not the exact operator" (Service.php:117, :131-134), so a Partial => 'LIKE' arm would route a non-numeric vendor code into that guard, is_numeric($bound) would fail, and no clause would be emitted at all — the filter silently drops and the read widens to every product, a worse outcome than a LIKE scan. Declaring Exact defuses that, because the type no longer routes anywhere near that arm.

  • [4.121.0] Check for overrides: Advisable\Domains\StorefrontConfig\StorefrontConfigProvider::__construct() gained a fourth required argument (VideoShowcaseConfigResolver) — the same break shape shipping (#643) caused. A fork re-declaring the service in its own container fails at container compile on the next upstream merge; a fork with a Custom\ subclass still calling parent::__construct() with the old argument count compiles fine and fails later as a runtime ArgumentCountError on first instantiation. A fork that has never touched src/Domains/StorefrontConfig/ needs no action.

  • [4.121.0] Check for overrides: application/config/rest_routes.php, rest_policies.php, rest_features.php and rest_api_versions.php all changed. A fork carrying its own copy of any of these must merge in the new Reel route block, the Reel::class policy entry, the videoShowcase feature key and the new 1.X entry respectively.

  • [4.121.0] Check for overrides: two new seams, both protected and non-final: Advisable\Domains\Cms\Reel\Repository\Repository::rowInvariant() — a fork overriding it must only ever NARROW the row set, never relax it, since the four clauses are the entire security boundary for this endpoint; and Advisable\Domains\Cms\Reel\Service::{decorate,decorateUrls,providerUrls,videoStream,storedFileUrl,requestLanguage}().

  • [4.121.0] Check for overrides: Advisable\Domains\Cms\Reel\Repository\Repository::productIdsFor() takes a second parameter (bool $scopeToVisibleProducts = true, fail-closed) and the repository's constructor now also takes ProductVisibilityScope. A fork constructing this repository directly, or overriding Advisable\Domains\Cms\Reel\Service::scopesProductIds(), must keep the default scoped — an override may only ever return false for more-privileged callers, never fewer.

  • [4.121.0] UI Update: a headless storefront currently reading reels from GET /rest/cms/video should move to GET /rest/cms/reel — the two endpoints project different tables with unrelated content.

  • [4.121.0] Behavioural note vs. the rendered storefront: a reel with no translation in the active language is returned here with name: null, whereas the legacy rendered storefront inner-joins the MUI table and omits such a reel from the listing entirely.

  • [4.121.0] No DB migration, no schema change, no write endpoint. OpenAPI annotations were added for the new endpoint and the new storefront-config section only; the published specs regenerate at the release cut, not in this branch.

  • [4.121.0] Check for overrides: a fork carrying its own copy of Advisable\Domains\Support\Repository\Specification\Filter — or its own Service that hand-builds a BETWEEN condition string instead of routing through this class — is still on the vulnerable shape and must take this change. Such a class is autowired by FQCN and constructs fine: the container boot stays green, nothing warns, and the endpoint keeps serving the injectable SQL. There is no error to notice this by.

  • [4.121.0] Check for overrides: a fork whose tests assert on emitted SQL text — the BETWEEN keyword, or the number of string literals in a compiled WHERE clause — must re-baseline those assertions. The rows the query returns do not move; only the SQL text producing them does.

  • [4.121.0] Filter stays non-final. The new bounds helper, betweenBounds(), is private, matching its sibling boundValue() — a fork can subclass Filter and override apply(), but there is no seam to reach it: Filter implements only the marker Specification interface, carries no container registration, and is constructed directly at all 85 new Filter(...) call sites, every one of which a subclass would have to replace. A fork needing different BETWEEN handling should expect to carry a patch, not an alias — one more reason to take this change rather than diverge.

  • [4.121.0] No DB migration, no OpenAPI schema change, no route change, no new configuration, no language-key change. application/config/rest_api_versions.php carries its own 1.X entry for this change under the per-task cadence, framed as a security fix with no contract change; that entry is authored and maintained separately from this fragment.

  • [4.121.0] UI Update: REST contract addition for headless storefront consumers, no removals and no breaking change. GET /rest/storefront-config now returns a third section alongside listing and loyalty:

    json
    "shipping": { "freeShippingThreshold": 65.0 }

    Stop hardcoding the free-shipping banner threshold — read it from here so it tracks what the merchant actually configured. Two things a client must know: the value is a display figure in the shop currency and must never be quoted as, or used to derive, a shipping price (take every payable figure from POST /rest/checkout/shipping / POST /rest/checkout/totals, which apply the per-transporter, per-country thresholds); and 0 means no threshold is configured, so the banner should be hidden rather than read as "free shipping on everything". A client that ignores the new section is unaffected.

  • [4.121.0] Check for overrides: Advisable\Domains\StorefrontConfig\StorefrontConfigProvider::__construct() gained a third required argument (ShippingConfigResolver). A fork must add it, and how the omission surfaces depends on which override mechanism the fork used — the two fail at different times, so check for both:

    • a fork re-declaring the service in its own custom/Domains/**/container.php fails at container compile on the next upstream merge, caught by composer run check-container before anything runs;
    • a fork with a Custom\ subclass whose constructor still calls parent::__construct() with the old two arguments compiles fine and fails later, as a runtime ArgumentCountError on first instantiation of the provider — still loud, but not caught by the compile check.

    A fork that has never touched src/Domains/StorefrontConfig/ needs no action. Note that docs/guides/RestApiModules.md documents the supported override mechanism as a custom/Domains/**/container.php alias/registration and does not document Custom\ subclassing of aggregator classes as an extension point, so the second path should be rare.

  • [4.121.0] No DB migration, no schema change, no route change, no auth change, no language-key change, no removed or renamed response field. A rest_api_versions.php entry is recorded under the per-task cadence, framed as an addition. OpenAPI annotations were added for the new section; the published specs regenerate at the release cut, not in this branch.

  • [4.121.0] Check for overrides: two shared platform classes changed, and a fork carrying its own copy of either gets a silent wrong answer rather than an error.

    • src/Domains/Support/Repository/Specification/Filter.php — four new switch branches ('>=', '>', '&lt;=', '&lt;') plus a new private boundValue() helper. A fork shipping its own copy of this file does not get them, so every one of the four operators falls through to its default arm, which is '='. The result is inverted, not merely wrong: filter[priceGt]=0 becomes price = 0, so a caller asking to exclude the zero-priced gift SKUs receives exactly and only those SKUs. No error, no warning, a green container boot, a 200 response.
    • src/Domains/Support/Request/QueryListBuilder/FilterRequestType.php — four new enum cases. A fork with its own copy will fatal on FilterRequestType::Gte etc. the moment its ListRequest (or an upstream one it inherits) references a case its copy lacks — that failure at least announces itself, unlike the Filter.php one.
    • Neither class is DI-registered or aliasable — both are plain value/specification classes instantiated directly — so a fork cannot override them with a Custom\ alias. Reconciling means re-applying the branches to its copy, or (better) deleting the copy.
  • [4.121.0] UI Update: REST contract addition for storefront consumers, no removals and no breaking change. GET /rest/product/product and /rest/product/product/item accept filter[priceGte], filter[priceGt], filter[priceLte], filter[priceLt]; filter[price] behaves exactly as before. A request that sends no bound returns the same rows it always did, including zero-priced and NULL-priced products — nothing is hidden by default. Two behaviours a client must know: a NULL-priced product disappears as soon as any bound is supplied, and a non-numeric bound is ignored silently (the request still returns 200 with that key's predicate simply absent) — there is no validation error to catch, so validate bounds client-side if a typo must be visible.

  • [4.121.0] Deliberate limitation, recorded so it is not read as an oversight: the non-numeric bound above is dropped rather than rejected. The platform has no 4xx path for filter values, and adding one for these four keys alone would be inconsistent with every other filter on every other endpoint.

  • [4.121.0] No DB migration, no schema change, no route change, no response-field change, no language-key change. OpenAPI annotations were added (four OA\Parameter entries on index() and on item(), four x-filters entries on the tag) — the generated specs regenerate at the release cut, not here. A rest_api_versions.php entry is recorded under the per-task cadence, framed as an addition.

  • [4.121.0] Out of scope, tracked separately: #645 — Filter::apply()'s BETWEEN branch concatenates both of its client values into a single-argument where(), which CI3 neither escapes nor binds. Untouched here on purpose; the four new branches are the safe shape, so this change does not extend that surface.

  • [4.121.0] Check for overrides: the client-facing surface of this delivery is tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php itself. A fork that has overridden or copied this test file will not pick up the new positive-invariant guard and stays silently unprotected against exactly the hazard described above — re-sync the fork's copy of this test file against upstream rather than keeping a divergent one.

  • [4.121.0] Check for overrides — reads no error, gives no warning, and keeps the hole open. Cms\Blog\Category\Repository\RepositoryConfigurator gained a new constructor (BlogCategoryVisibilityScope, ArticleVisibilityScope) where it previously took none, and Cms\Blog\Article\Repository\RepositoryConfigurator (already constructor-injected since #637) gained two more parameters (BlogCategoryVisibilityScope, BlogTagVisibilityScope); the sibling Cms\Page\Repository\RepositoryConfigurator gained the same shape of new constructor — see the #639 fragment. If your fork has already forked any of these three classes, it keeps its own copy. That copy is autowired by FQCN, constructs perfectly fine with the old argument list, and returns visibilityScope === null — no exception, no boot warning, a green check-container. It simply keeps serving inactive taxonomy and draft article bodies (and, on the sibling class, unpublished pages) to guests. This is the #417 fork-fatal shape but worse, because #417 at least fatals — here the failure is silent by construction. Injecting the dependency only fixes the path that resolves to the upstream class.

    • What to do instead of patching the fork's copy. Symfony DI here is last-wins by module load order, so the recommended fix is to drop the forked configurator and rebind the scope service from the fork's own custom/Cms/container.php, registered after the upstream module — this keeps upstream's configurator, so the fork inherits every relation and scope upstream adds to it later, instead of freezing a copy at the version it was forked from:
      php
      $services->set(\Custom\Cms\Blog\Category\BlogCategoryVisibilityScope::class);
      $services->alias(
          \Advisable\Domains\Cms\Blog\Category\BlogCategoryVisibilityScope::class,
          \Custom\Cms\Blog\Category\BlogCategoryVisibilityScope::class
      );
      All four scope classes shipped in this delivery (PageVisibilityScope, BlogCategoryVisibilityScope, BlogTagVisibilityScope, ArticleVisibilityScope) are protected table() / column() rather than a private const, precisely so a subclass can retarget the table or column without copying the closure body — a private const read as self::TABLE inside an inherited method is early-bound, so a subclass redefining the constant would be silently ignored.
    • If a fork genuinely must keep its own copy of a configurator, it has to add both the constructor dependency and pass visibilityScope: as a named argument — Relation::__construct's tenth parameter is $visibilityScope, and a positional tenth argument lands silently in $pivotTable instead.
    • #641 is the structural fix for this class of silent loss across the whole platform, not this delivery — and worth knowing its limit going in: its custom/Domains scan is an allow-list, so it would not by itself catch a dropped scope on a class a fork still carries.
  • [4.121.0] UI Update: on the four collection embeds here — Article.categories, Article.tags, BlogCategory.children, BlogCategory.articles — a hidden row simply drops out of the array, so the list is shorter and may now be empty; no key is removed and no error is raised. BlogCategory.parent is the one single embed — a hidden parent now serializes as null, the same shape a root category (no parent at all) already produced, so a client that already null-checks parent needs no change. The owning list itself is not filtered by any of these scopes and pagination counts do not move — a published article with a hidden category is still returned as an article, just with a shorter categories array.

  • [4.121.0] Backend callers are unaffected on all five relations — the scope is suppressed centrally by the single Relation::VISIBILITY_EXEMPT_ALL grant HandlesRestfulActions applies whenever the request ResourceContext is backend, so no per-relation admin wiring was needed and the admin blog tree (where an inactive category gets re-activated, or a draft gets edited) keeps seeing every row.

  • [4.121.0] NULL-flag behaviour — a deliberate decision, not a side effect. blog_categories.is_active and blog_tags.is_active are int(2) DEFAULT NULL, and forcing = 1 withholds a NULL-flagged row from storefront callers along with an explicitly-0 one. That matches legacy, which tests is_active = 1 and never != 0 wherever it consults the flag at all.

  • [4.121.0] Read the two taxonomy tables separately — they are not the same rule wearing two names. BlogTagVisibilityScope is straight parity with Adv_blog_tags_model::getTagsFront(), which already selects is_active = 1 on the legacy storefront. BlogCategoryVisibilityScope is a deliberate tightening: the legacy blog-category storefront never filters is_active at all, so inactive and NULL-flagged categories are visible there today and stop being served over REST as of this change. This is why two scope classes ship instead of one parameterised by table — collapsing them would have hidden that one is a restatement of existing behaviour and the other is not.

  • [4.121.0] ArticleVisibilityScope is registered public and autowired at the Article domain root specifically so #625 can consume it cross-module (the product-category → article relation #625 covers). Do not move it into a narrower namespace or drop its public visibility without checking #625 first — that issue is currently blocked on this scope existing where it is.

  • [4.121.0] No DB migration, no OpenAPI schema change, no route change, no response field added or removed. The five relation declarations are the entire behavioural change.

  • [4.121.0] application/config/rest_api_versions.php carries its own 1.X entry for this change (delivered together with #639 as one mechanism over one module) under the per-task cadence. That entry is authored and maintained separately from this fragment.

  • [4.121.0] Check for overrides — reads no error, gives no warning, and keeps the hole open. Cms\Page\Repository\RepositoryConfigurator gained a new constructor (PageVisibilityScope $pageVisibilityScope) where it previously took none, and its sibling Cms\Blog\Category\Repository\RepositoryConfigurator gained one too, while Cms\Blog\Article\Repository\RepositoryConfigurator (already constructor-injected since #637) gained two more parameters — see the #640 fragment for those two. If your fork has already forked any of these three classes, it keeps its own copy. That copy is autowired by FQCN, constructs perfectly fine with the old argument list, and returns visibilityScope === null — no exception, no boot warning, a green check-container. It simply keeps serving unpublished pages (and, on the sibling classes, inactive taxonomy and draft article bodies) to guests. This is the #417 fork-fatal shape but worse, because #417 at least fatals — here the failure is silent by construction. Injecting the dependency only fixes the path that resolves to the upstream class.

    • What to do instead of patching the fork's copy. Symfony DI here is last-wins by module load order, so the recommended fix is to drop the forked configurator and rebind the scope service from the fork's own custom/Cms/container.php (or the relevant module's), registered after the upstream module — this keeps upstream's configurator, so the fork inherits every relation and scope upstream adds to it later, instead of freezing a copy at the version it was forked from:
      php
      $services->set(\Custom\Cms\Page\PageVisibilityScope::class);
      $services->alias(
          \Advisable\Domains\Cms\Page\PageVisibilityScope::class,
          \Custom\Cms\Page\PageVisibilityScope::class
      );
      PageVisibilityScope (and its three siblings shipped alongside it) is protected table() / column() rather than a private const, precisely so a subclass can retarget the table or column without copying the closure body — a private const read as self::TABLE inside an inherited method is early-bound, so a subclass redefining the constant would be silently ignored.
    • If a fork genuinely must keep its own copy of the configurator, it has to add both the constructor dependency and pass visibilityScope: as a named argument — Relation::__construct's tenth parameter is $visibilityScope, and a positional tenth argument lands silently in $pivotTable instead.
    • #641 is the structural fix for this class of silent loss across the whole platform, not this delivery — and worth knowing its limit going in: its custom/Domains scan is an allow-list, so it would not by itself catch a dropped scope on a class a fork still carries.
  • [4.121.0] Check for overrides — this delivery's write-path exemption assumes your Page REST policy. Cms\Page\WriteService::update() reloads children and parent through a server-side relation list and passes [Relation::VISIBILITY_EXEMPT_ALL] unconditionally — it does not consult the caller's ResourceContext. That is correct upstream only because application/config/rest_policies.php pins Page::class to auth => backend with ADMIN + CMS and overrides only index/show/item to guest, so a write response is a backend context by construction. rest_policies.php is a file forks commonly keep a local copy of (see the Badge case in [4.120.0]), and a diverged copy breaks that premise passively — the fork need not change anything at merge time. If your Page row opens update to a non-backend caller (any, customer, legacy_guard, or a custom write method inheriting the controller defaults), PUT /rest/cms/page/{id} returns unpublished pages and draft children to that caller behind a 200, with no error and a green check-container. The same applies if fork code resolves Advisable\Domains\Cms\Page\WriteService directly — it is a public service — and calls update() from a guest-reachable action.

    • What to do. Confirm your Page policy still keeps update backend-only. If you deliberately open it, do not widen the exemption list: thread the real ResourceContext into the call instead, so the exemption follows the caller rather than being asserted for all of them.
  • [4.121.0] UI Update: GET /rest/cms/page?with=children (and ?with=children*) is a collection embed — a hidden page simply drops out of the array, so the list is shorter and may now be empty; no key is removed and no error is raised. ?with=parent is a single embed — a hidden parent now serializes as null, the same shape a root page (no parent at all) already produced, so a client that already null-checks parent needs no change. The owning list itself is not filtered by either scope and pagination counts do not move.

  • [4.121.0] Backend callers are unaffected on both relations — the scope is suppressed centrally by the single Relation::VISIBILITY_EXEMPT_ALL grant HandlesRestfulActions applies whenever the request ResourceContext is backend, so no per-relation admin wiring was needed and the admin CMS page tree (where an unpublished page gets authored and later published) keeps seeing every row.

  • [4.121.0] No DB migration, no OpenAPI schema change, no route change, no response field added or removed. The two relation declarations are the entire behavioural change.

  • [4.121.0] application/config/rest_api_versions.php carries its own 1.X entry for this change (delivered together with #640 as one mechanism over one module) under the per-task cadence. That entry is authored and maintained separately from this fragment.

  • [4.121.0] Check for overrides: all nine RepositoryConfigurator constructor signatures changed from implicit-no-arg to requiring one ProductVisibilityScope:

    • Advisable\Domains\Cms\Blog\Article\Repository\RepositoryConfigurator
    • Advisable\Domains\Event\Event\Repository\RepositoryConfigurator
    • Advisable\Domains\Cms\Video\Repository\RepositoryConfigurator
    • Advisable\Domains\Product\Promo\Repository\RepositoryConfigurator
    • Advisable\Domains\Product\Variation\Value\Repository\RepositoryConfigurator
    • Advisable\Domains\Product\Variation\Repository\RepositoryConfigurator
    • Advisable\Domains\Product\Review\Repository\RepositoryConfigurator
    • Advisable\Domains\Product\PriceTracking\Repository\RepositoryConfigurator
    • Advisable\Domains\Product\Wishlist\Repository\RepositoryConfigurator

    The critical fact, and the single biggest client-merge cost of this delivery: a fork keeping its own copy of any of these nine silently loses the scope rather than failing loudly. The fork's copy is autowired by FQCN and constructs perfectly fine — it just returns a Relation with visibilityScope === null, so that fork keeps serving inactive and soft-deleted products to guests with no error, no warning, and a green container boot. This is the #417 fork-fatal shape but worse, because #417 at least fatals; here the only symptom is that the security hole stays open. A fork must add both the constructor parameter and the visibilityScope: named argument to every copied configurator. Grep custom/Domains/** for copies of those nine class names as a merge checklist item.

  • [4.121.0] Check for overrides: a fork carrying its own copy of tests/Unit/Domains/Cms/Blog/Comment/CommentVisibilityScopeTest.php — or any test that constructs one of these nine configurators directly — needs the same one-line argument fix. Upstream hit exactly this: that test's test_article_comments_carries_both_slots() did new ArticleConfigurator() with no arguments and started throwing ArgumentCountError once the constructor gained its required parameter.

  • [4.121.0] application/config/rest_api_versions.php carries its own 1.X entry for this change under the per-task cadence, framed as a breaking removal (relation reads only — no route, filter, sort or field change). That entry is authored and maintained separately from this fragment.

  • [4.121.0] No DB migration, no OpenAPI schema change, no route change, no language-key change, no response field added or removed. The nine relation definitions are the entire behavioural change.

  • [4.121.0] UI Update: REST contract addition for storefront consumers, purely additive. GET /rest/product/category, /item and /{id} (plus their locale-prefixed twins) now return one additional boolean key, isSensitive, to guest and authenticated-customer callers. Nothing is removed, no existing value changes, and backend payloads carry the same keys with the same values — only isSensitive's serialization position shifts (base block instead of backend-only block), so member order moves while content does not; a JSON object is unordered and no conforming client may depend on member order, so backend callers remain unaffected in every way that matters. A headless storefront can now reproduce legacy's sensitive-category menu presentation (e.g. an age-gate icon or a "sensitive" badge) using the same flag legacy already renders on.

  • [4.121.0] Check for overrides: a client fork carrying its own Custom\ copy of Category/Resource.php — rather than extending the upstream class — must apply the same move (isSensitive out of the isBackend() block) to gain parity; upstream cannot detect or warn about a wholesale fork copy.

  • [4.121.0] Not addressed here, by design — a separate, row-level question. Legacy's child listing additionally excludes sensitive categories server-side (Adv_product_category_model.php:957, ->where($this->table . '.is_sensitive', 0)), while REST applies no such row filter and returns those rows to a storefront caller regardless of this change. That is a row-scoping question, not the field-parity question this fix answers, and closing it here would mean adding a forced filter — a #618-family mechanism — on top of a change that points the opposite direction (making a field public, not withholding rows). Recorded so its absence is not read as an oversight; it is deliberately deferred and needs its own issue, which has not been filed yet.

  • [4.121.0] No DB migration, no route change, no auth/policy change, no language-key change, no field renamed or removed. application/config/rest_api_versions.php gains one entry under the per-task cadence, framed as additive (the opposite of the #618 family's removal framing). Published OpenAPI specs regenerate at the release cut, not in this branch.

  • [4.121.0] No npm run build needed. The change touches no assets/**, no .vue file, no SCSS and no laravel-mix config — the two new fields are a CI3 view (boxNowSettings.php) and PHP-only. Do not run npm run all-production for this change.

  • [4.121.0] Two new global helper functions in ecommercen/helpers/transporters_helper.php: getBoxNowPaperSizeDropDown() and getBoxNowLabelsPerPageDropDown(), both function_exists-guarded like the rest of that file. They are prefixed per transporter on purpose — EltaHelper::paperSizes() is index-keyed, SkroutzHelper::paperSizes() returns ['A4' => 'A4', 'thermal' => 'Thermal'] and BoxNowHelper::paperSizes() returns ['A4' => 'A4', 'A6' => 'A6'], so a single shared getPaperSizeDropDown() would not be safe.

  • [4.121.0] Check for overrides:

    • Advisable\Transporters\BoxNow\BoxNowConfig::getFieldConfiguration() and ::initialize() — a fork overriding either method will not pick up PAPER_SIZE / LABELS_PER_PAGE until it merges these additions in; until then the settings screen and the SaaS wizard silently omit both fields for that fork.
    • Adv_transporters_model::saveBOXNOWSettings() — a fork overriding this method drops PAPER_SIZE and LABELS_PER_PAGE on save. The method grew from 11 rows to 13.
    • Adv_transporters_admin::validateSettingsBOXNOW() — a fork overriding this method needs the two added rules (PAPER_SIZE → trim|in_list[A4,A6], LABELS_PER_PAGE → trim|in_list[1,2,4]) or those fields fail validation.
    • AdvPrintVoucher::printBatch() — a fork overriding the whole method does not get the new BOXNOW case and keeps forcing single-voucher printing for BoxNow.
    • application/views/admin/transporters/settings/boxNowSettings.php — this view lives under application/, so a client fork almost certainly ships its own copy and will not get the two new dropdowns automatically. This is the most likely override to bite; reapply the PAPER_SIZE / LABELS_PER_PAGE form_group blocks — including their form_error() calls — by hand after the next upstream merge. The option lists themselves need no src/ change to retarget, though — because the view calls the two transporters_helper functions, a fork can redefine them from application/helpers/transporters_helper.php, which MY_Loader::ecomnHelper() loads ahead of the platform copy.
    • $config['transportersSupportingBatchVoucherPrint'] in application/config/app.php — a fork with its own app.php must add 'BOXNOW' itself, or the mass-print bulk action will not appear in that fork's admin order list.
  • [4.121.0] Flow doc updated. The carrier table in docs/flows/admin/AD-34-voucher-generation.md recorded BOXNOW as Batch Print: No; it now reads Yes (single request), matching the no-chunking decision above.

  • [4.121.0] No DB migration, no REST API contract change (src/Rest/** untouched — rest_api_versions.php needs no entry), and no new environment variable.

  • [4.121.0] Check for overrides: Adv_orders_admin is a main-repo legacy controller. A client fork that has overridden this controller — or copied/overridden the validation-rule method containing line 690 — does not inherit this fix and keeps rendering the raw key; verify your own fork's override status before assuming it applies.

  • [4.121.0] No client language-file action needed. Unlike #630, this fix adds no new keys — a fork maintaining its own adv_advisable_lang.php copies needs no edit here, provided its copies already carry the plural eshop.admin.order.select.smart.points.

  • [4.121.0] No schema, contract, or REST surface change — a legacy admin-side label lookup only.

  • [4.121.0] Check for overrides: nothing in the main repo currently wires up GoogleRecaptchaTrait — ecommercen/core/Adv_front_controller.php uses the inert base CaptchaTrait, whose captchaCheck() returns true unconditionally — so this defect was only ever reachable in a client that wires the Google trait in itself. A fork that has overridden or copied setCaptchaValidationRule() does not inherit this fix and needs to re-apply both the set_message() call and the field label by hand.

  • [4.121.0] Client language files: a fork maintaining its own copies of adv_theme_lang.php needs both new keys (captcha.validation.error, captcha.field.label) added, or t() will emit the raw key text to visitors and log a miss for every failed captcha.

  • [4.121.0] Because set_message() is keyed by rule name (captchaCheck) rather than field name, the in-flight #586 — which adds a third callback_captchaCheck rule for its v3 field — inherits this message automatically once it merges develop.

  • [4.121.0] Check for overrides: Slide::index()/item() hand-copy HandlesRestfulActions's base actions rather than calling parent::, and both now call a new protected enforceStorefrontSlideScope() immediately before buildListRequest(). A client fork that overrides Slide::index()/item() wholesale (rather than extending the shipped methods) does not inherit this call and must add it, or filter[audienceId] stays a live oracle on that fork. The method is protected, not private, precisely so a fork can instead widen or narrow the rule by overriding enforceStorefrontSlideScope() itself and calling parent:: — the seam private would have foreclosed.

  • [4.121.0] UI Update: GET /rest/slider/slide and GET /rest/slider/slide/item (plus locale-prefixed twins) now silently drop filter[audienceId] for guest and authenticated-customer callers — the request still returns 200 with the filter simply not applied, there is no new error response. A headless storefront that sent this filter to pre-narrow a slide query must stop; there is no storefront replacement, since audience targeting is a server-side visibility decision. Backend callers are unaffected.

  • [4.121.0] Check for overrides: the controller. Advisable\Rest\Product\Controllers\Category now uses the ScopesStorefrontRows trait and calls enforceStorefrontRowScope() in all three read actions (index(), item(), and show(), which additionally calls denyHiddenStorefrontRow()). A fork overriding index(), show() or item() without calling parent:: keeps the vulnerability silently — no error, a green container boot, just an unscoped endpoint. The cheapest override point is storefrontRowScope(): StorefrontRowScope; an override must only ever narrow what a storefront sees, and the per-row gate must stay fail-closed. Grep custom/Rest/** for overrides of this controller as a merge checklist item.

  • [4.121.0] Check for overrides: the configurator — this one fails silently. Advisable\Domains\Product\Category\Repository\RepositoryConfigurator gained a constructor, now taking CategoryVisibilityScope and ArticleVisibilityScope where it previously took none. A fork keeping its own copy is autowired by FQCN, constructs fine, returns visibilityScope === null, and keeps serving hidden categories and draft article bodies to guests with a green boot.

    • Preferred: drop the forked configurator and rebind the scope service instead — Symfony DI here is last-wins by module load order, so from the fork's own custom/Product/container.php:
      php
      $services->set(\Custom\Product\Category\XVisibilityScope::class);
      $services->alias(
          \Advisable\Domains\Product\Category\CategoryVisibilityScope::class,
          \Custom\Product\Category\XVisibilityScope::class
      );
      This keeps upstream's configurator, so the fork inherits every relation upstream adds later.
    • If a fork must keep its own copy: add both dependencies and pass visibilityScope: as a named argument — named because it is Relation::__construct's tenth parameter, and a positional tenth lands in $pivotTable, which for the two MANY_TO_MANY relations (relativeCategories, articles) is live data corruption, not a no-op.
  • [4.121.0] UI Update: a storefront that rendered unpublished categories, or reached draft articles through a product category, loses those rows. The three collection embeds (children, relativeCategories, articles) return shorter or empty arrays — not an error, no key removed — while the single parent embed serialises as null, the same shape a root category already produced. The owning list is not filtered by a relation rule and pagination counts do not move. Re-baseline any fixture, snapshot or contract test that counted on those rows; a caller that legitimately needs them must use a backend token.

    The endpoint and the relations use two different definitions of a visible category, on purpose. The endpoint applies the category's own flag (published = 1); the relations apply the stronger ancestor-chain rule. So a category that is published = 1 beneath an unpublished ancestor remains enumerable from GET /rest/product/category while correctly vanishing from ?with=children. This is deliberate: withMandatoryFilter() sets a scalar and structurally cannot express the where_not_in an ancestor-chain rule needs, and the exploit actually being closed here is ?filter[published]=0. The population this leaves open is not marginal — categories hidden by the chain rule but not by their own flag measure 1.2% of wecare, 3.7% of smile, 42% of pharm16 — so a fork owner should judge their own exposure rather than assume it small. Closing the gap would need a new endpoint seam and is deliberately out of scope here.

  • [4.121.0] No DB migration, no route change, no new configuration key, no OpenAPI schema change (the affected read actions' annotations were updated; specs regenerate at the release cut, not here). application/config/rest_api_versions.php carries its own 1.X entry for this change, authored and maintained separately from this fragment.

  • [4.121.0] Check for overrides: the five hand-duplicated scope helpers have been collapsed into a shared trait, Advisable\Rest\Support\Controllers\ScopesStorefrontRows, used by all five controllers (Page, BlogArticle, Document, BlogCategory, BlogTag), plus a small carrier value object, StorefrontRowScope (filterKey, flagProperty, deniedSort). The override surface is now the trait's methods — all protected, none private — not per-controller duplicates: storefrontRowScope(): StorefrontRowScope (abstract; each controller implements it, supplying its own key/flag/denial and carrying that endpoint's rationale in its docblock — this is the cheapest override point, for changing a filter key, flag property or denied sort), enforceStorefrontRowScope(): void (the isBackend() early return, the forced filter, the sort denial), denyHiddenStorefrontRow(int|string $id): bool (the per-row gate on show(), returning true to stop so the 404 body stays byte-identical to a genuinely missing row's), and isStorefrontVisible(object $entity): bool (still direct property access with a cast, never isset()). A trait method is flattened into the using class, so a fork adding a second visibility column overrides enforceStorefrontRowScope() and isStorefrontVisible() and calls parent:: in both, exactly as it would for an inherited method. There is no constructor-signature hazard here — that shape belongs to #637, not this delivery. The contract is unchanged: an override must only ever narrow what a storefront sees, and the per-row gate must stay fail-closed. Load-bearing warning: a fork that overrides any of the five controllers' index()/show()/item() without calling parent:: keeps the vulnerability, silently — no error, a green container boot, just an unscoped endpoint. Grep custom/Rest/** for overrides of these five controllers as a merge checklist item.

  • [4.121.0] Check for overrides: src/Domains/Cms/Page/ListRequest.php had its two commented-out stubs (isPublished in $defaultFilters, order in $defaultSorts) deleted. A fork that had uncommented either in its own copy must remove it: $defaultFilters is context-blind, so it would blind the admin listing; it carries key = null, so a client value ANDs into an empty set rather than being overridden; and $filter['value'] ?: null coerces a falsy 0, so it cannot express "flag = 0" anyway.

  • [4.121.0] Check for overrides: a fork whose storefront renders blog categories will see rows disappear — but not for both tables. blog_tags is no change from legacy: its storefront tag listing already selected is_active = 1 (ecommercen/blog/models/Adv_blog_tags_model.php:24-27, getTagsFront()), so an inactive or NULL-flagged tag was already invisible there. blog_categories is where rows genuinely disappear: legacy never filtered is_active on the storefront at all — Adv_blog_category_model.php:214's getCategoriesFront() applies only caller-supplied conditions and all five call sites in ecommercen/blog/controllers/Adv_blog.php (:135, :306, :366, :579, :1009) pass lang alone, getCategoryBySlug() (:106) and getCategoryData() (:166) filter on slug and lang only, and the column's only two appearances (:202, :236) emit the value rather than filter by it. A fork whose storefront currently renders inactive or NULL-flagged blog categories — in a nav menu, a category list, a sidebar, a filter widget — will find those categories gone from /rest/cms/blog/category for storefront callers. Audit any such surface: an intentionally-rendered row must either be activated (is_active = 1) or the surface must move to a backend-token call. The affected row population is unmeasured — check your own data rather than assume the set is empty.

  • [4.121.0] application/config/rest_api_versions.php carries its own 1.X entry for this change under the per-task cadence, framed as a breaking removal. That entry is authored and maintained separately from this fragment.

  • [4.121.0] No DB migration, no OpenAPI schema change, no route change, no new configuration, no language-key change. OpenAPI annotations on the affected read actions were updated to document the scope (specs regenerate at the release cut, not here).

  • [4.121.0] Check for overrides: two new shared base-class seams, both additive and opt-in — a fork that never calls them is unaffected, but a fork carrying its own copy of either base class must take them or its own subclasses will fatal on the call.

    • Advisable\Domains\Support\Request\QueryListBuilder\GenerateListRequest::denySort(string $key): static — new public method. It also adds a private $deniedSortKeys property and two new early-return branches inside the existing protected parseSortValue() and parseRelationSort(); a fork that overrides either of those two methods silently loses the denial and must re-apply the guard.
    • Advisable\Rest\Support\Controllers\HandlesRestfulActions::withDeniedSort(string $key): void — new protected method, and protected buildListRequest() now forwards denied sorts as well as denied filters. A fork overriding buildListRequest() must forward them too.
  • [4.121.0] Check for overrides: Advisable\Rest\Cms\Controllers\BlogComment gains protected enforceStorefrontCommentScope(), called from index() and item(); Advisable\Rest\Cms\Controllers\BlogArticle gains protected denyStorefrontCommentEmailSort(), called from index(), show() and item(). A fork that overrides any of those read actions without calling the scope method re-opens the leak for its own storefront. A fork needing a different storefront rule should override the scope method — both are protected for exactly that — rather than the actions.

  • [4.121.0] Check for overrides: Advisable\Rest\Cms\Controllers\BlogComment::show() is no longer a bare parent::show($id); it performs the per-row approval check first. A fork overriding show() loses the 404 gate.

  • [4.121.0] Check for overrides: Advisable\Domains\Cms\Blog\Article\Repository\RepositoryConfigurator and Advisable\Domains\Cms\Blog\Comment\Repository\RepositoryConfigurator now attach visibilityScope: to the comments, children and parent relations. A fork that overrides either configurator by DI alias (the supported seam) and rebuilds the relation array from scratch drops the scope and serves unmoderated comments again. Do not merge the two slots on comments — scope is the structural parent_comment_id IS NULL rule that binds everyone, visibilityScope is the approved-only rule that backend callers are exempt from.

  • [4.121.0] Check for overrides: new class Advisable\Domains\Cms\Blog\Comment\CommentVisibilityScope, holding public const STATUS_APPROVED = 'approved' and the static relationScope(): \Closure factory. It is the single source of the approved literal — read the constant rather than re-declaring the string.

  • [4.121.0] UI Update: REST contract change, breaking for storefront consumers of GET /rest/cms/blog/comment (index / /item / /{id}) and of ?with=comments[.children] on GET /rest/cms/blog/article.

    • Fields removed for guest and customer callers: email and status — absent from the payload, not nulled, including inside children and parent.
    • Rows removed: only approved comments are returned, and /{id} now answers 404 for a pending or rejected comment.
    • Request parameters removed for those callers: filter[email], sort=email, sort=children.email and sort=comments.email. All are dropped silently with a 200, exactly as any unrecognised key already is — the platform has no invalid-filter or invalid-sort error response — so a client will see a different result set, not an error. filter[status] is ignored in favour of the forced approved.
    • A headless storefront that rendered the commenter email or the moderation status must stop; there is no replacement, both are backend-only now. One that paged, searched or ordered by email must drop those parameters. Any fixture, snapshot or contract test baselined on unmoderated comments must be re-baselined against approved-only.
    • Regenerated types will still declare email and status, because a backend caller does receive them — a codegen diff will look clean. Treat both as optional at every call site rather than trusting the generated shape.
    • Admin and back-office surfaces are unchanged.
  • [4.121.0] Check for overrides: Advisable\Rest\Customer\Resources\Customer\Resource gained two new protected methods, isSelfOrBackend() and isSelf(), and its resource()/addRelationToData() bodies were restructured — a fork that overrides resource() or addRelationToData() on this class keeps its own ungated version and stays vulnerable, and must re-apply the gate. Advisable\Rest\Plus\Resources\Audience\Resource::addRelationToData() and Advisable\Rest\Slider\Resources\Slide\Resource::addRelationToData() changed the same way. Advisable\Rest\Slider\Controllers\Slide::__construct() gained a required sixth argument (SlideVisibilityFilter $slideVisibility) — a fork subclassing or re-registering that controller will fatal until it passes it, and src/Rest/Slider/container.php shows the wiring. application/config/rest_policies.php is a config file forks commonly copy wholesale: a fork on its own local copy does not get the Slide/Slider relations allow-lists and must re-apply them (including 'default' => [], without which the middleware fails open) after its next upstream merge.

  • [4.121.0] UI Update: any headless storefront or client UI that read customer fields out of a nested embed must stop — an audience roster, a slide's audience object, or an order/wishlist customer object belonging to anyone but the caller now carries only {"id": N}, and those fields will not come back. GET /rest/customer/me, the admin customer screens, and ?with=customer on the caller's own orders and wishlist are unaffected and need no change. /rest/slider/slide now returns fewer slides to the storefront (expired, not-yet-active and audience-restricted ones are withheld) and 404s for such a slide requested by id, so any UI counting on the full slide list — or on a fixture, snapshot or contract test pinning a nested customer payload, a slide audience, an audience customers array, or the unfiltered slide list — must be re-baselined.

  • [4.121.0] No DB migration, no new config key, no language-key changes. application/config/rest_api_versions.php carries the matching entry under the per-task cadence, framed as a breaking removal from the storefront contract.

  • [4.121.0] Not fixed here, deliberately: RelationFilterMiddleware's second fail-open (it inspects only the first ?with= hop, so a nested relation is never matched against any allow-list) is a separate agreed deliverable and is out of scope for this branch — the resource-level gates are what make this issue closed without it.

  • [4.121.0] Check for overrides: Advisable\Rest\Transporter\Resources\Setting\Resource::resource() changed shape — a fork that overrides this resource, or subclasses it, keeps emitting regValue to guests unless it reapplies the ResourceContext::isBackend() gate. application/config/rest_policies.php is also a config file forks commonly ship a local copy of: such a fork will not pick up the relations allow-list and its ?with=settings stays wide open to anonymous callers until it reapplies the Transporter::class block after its next upstream merge. Both halves are needed — they close disjoint sets, so reapplying either one alone leaves a hole. Reapplying only the resource gate still leaves the other six ADMIN-only relations (pricing, optionPricing, postPricing, countyAvailabilities, publicMappings, postAvailabilities) fully guest-readable: this change adds a gate to Setting alone, and no other src/Rest/Transporter/Resources/*/Resource.php carries one, so those six are closed by the allow-list and nothing else. Reapplying only the allow-list leaves a second-hop ?with=transporter.settings off any sub-endpoint reaching ungated credentials.

  • [4.121.0] UI Update: breaking removal from the storefront REST contract. Any client reading transporter.settings, .pricing, .optionPricing, .postAvailabilities, .postPricing, .countyAvailabilities or .publicMappings with a guest or customer token now receives a response with those keys absent — not present-and-empty — because a denied relation is stripped from ?with= before the repository loads it, and the request still returns 200. regKey/regValue are likewise absent from any non-backend serialization of a transporter setting. Such a client must move to the endpoint that owns the data (POST /rest/checkout/shipping for priced courier options, /rest/transporter/{id}/smart-point, /dhl-rates, /asap-services) or authenticate as a backend user. A rest_api_versions.php entry is recorded under the per-task cadence, described as a removal.

  • [4.121.0] Docs updated: docs/flows/admin/AD-06-transporter-admin.md's Relations (via ?with=) line now states the scope-gated reality — backend gets all eight; customer/public get translations only; the other seven are stripped and absent, each being a backend + AUTH_ROLE_ADMIN endpoint in its own right — and its Setting sub-resource section now notes regKey/regValue are serialized only when ResourceContext::isBackend(); Last Updated bumped to 2026-08-18. docs/guides/rest-middleware.md gained a TransporterSetting row in the "Fields filtered per resource" table for regKey/regValue, dropped the now-false claim that all Transporter settings are "fully backend-only ... do not need filtering", and corrected "Today only Customer::class and Line::class declare one" to include Transporter::class (#551, #612, #614). Still deferred: the tag-level x-relations block on src/Rest/Transporter/Controllers/Transporter.php still advertises all eight relations flatly — deliberately, since the x-relations schema has no scope field and #612 left Line.products unmarked there for the same reason. The three with parameter descriptions on the read endpoints were updated in this change.

  • [4.121.0] No DB migration, no new config key, no language-key changes, no OpenAPI schema change beyond two field descriptions on TransporterSettingResource (specs regenerate at the release cut, not in this branch).

  • [4.121.0] Known residual, out of scope by agreement: RelationFilterMiddleware has a second fail-open — a policy that declares relations but omits 'default' still skips filtering for any scope it does not name. This change declares 'default' => [] and pins it in a test, but hardening the middleware itself is a separate deliverable.

  • [4.121.0] Check for overrides: four surfaces a client fork can be sitting on top of.

    • src/Rest/Product/Controllers/Product.php — index(), item() and show() all changed, and two new protected methods are added: enforceStorefrontProductScope() and isStorefrontVisible(object $entity). A fork that overrides any of the three read actions and does not call enforceStorefrontProductScope() (or reproduce show()'s per-row gate) re-opens the leak silently — the response looks normal, it just contains rows it should not. The two helpers are protected deliberately, so a fork with an extra visibility column of its own can extend the scope instead of copying the whole action; an override must only ever narrow what a storefront sees (call parent:: and add filters), and isStorefrontVisible() must stay fail-closed.
    • src/Domains/Product/Line/Repository/RepositoryConfigurator.php — constructor signature changed: it now takes ProductVisibilityScope. A fork with its own Custom\ copy of this configurator, or one that instantiates it directly, must add the argument or it will fatal on container boot; a fork that keeps its old copy silently loses the products scope.
    • src/Domains/Product/container.php — new Product\ProductVisibilityScope::class registration. A fork shipping its own copy of this container file must add it, or Line\Repository\RepositoryConfigurator cannot be autowired.
    • application/config/rest_policies.php — a file forks commonly copy wholesale. Only a comment block changed here (the Line::class note describing the then-open #613 leak was corrected), so a stale fork copy is not a security regression — but its comment will now be describing a leak that no longer exists.
  • [4.121.0] UI Update: REST contract change for storefront consumers, and one part of it is breaking. GET /rest/product/product/{id} now returns 404 to a guest or customer for an inactive or soft-deleted product, where it previously returned the row — any client deep-linking to a product by id must handle that 404. Collection reads change shape too: GET /rest/product/product and /item now return only active = 1, soft_delete = 0 rows for storefront callers, and ?filter[active] / ?filter[softDelete] are ignored for them (silently server-forced, not rejected — the request still returns 200). ?with=lines.products returns only visible products at any nesting depth. Zero-priced products are still returned — deliberately, they are the gift-option SKUs the storefront renders. Nothing changes for a backend/admin UI: same rows, same filters, same sorts. No response field was added, removed or renamed.

  • [4.121.0] No DB migration, no OpenAPI schema change, no route change, no language-key change. OpenAPI annotations on the three read actions were updated to document the scope (specs regenerate at the release cut, not here). A rest_api_versions.php entry is recorded under the per-task cadence, framed as a breaking removal.

  • [4.121.0] Still open, for #618: the same relation-level exposure survives on other guest-readable endpoints that embed Product through their own relations — a visibilityScope does not cascade across relation definitions, so each needs its own. Guest-reachable today: Video.products, Event.products, BlogArticle.products, Promo.products, VariationValue.products, Variation.product, Review.product, PriceTracking.product. Backend-only, so lower priority: Download.product, ProductMeta.product, Related.product / Related.relatedProduct, ProductList\ProductLp.product, WaitingList.product, Wishlist.product (customer-scope), ProductCode.product (reachable at depth from customer-scope cart/order reads).

  • [4.121.0] Check for overrides: application/config/rest_policies.php is a config file client forks commonly copy. A fork shipping its own local copy will not pick this up — its Line::class row stays backend-gated and its storefront keeps getting 401, until it reapplies both the methods block and the relations allow-list after its next upstream merge. Reapplying the methods block without the relations allow-list opens ?with=products to guests and leaks inactive and soft-deleted products.

  • [4.121.0] New public surface on a support class: WithParser::splitRelations() and WithParser::topLevelName() (src/Domains/Support/Request/WithParser.php) are additive — both wrap logic that already existed as private helpers. No signature changed, no BC break.

  • [4.121.0] Follow-up, deliberately not in this change — tracked as #613, not a nice-to-have: the storefront can already reach these same inactive/soft-deleted product rows today, unscoped, via GET /rest/product/product?filter[active]=0 and via ?with=lines.products — neither route is affected by Line's new allow-list. #613 covers giving Product (and Line.products) a Relation::$visibilityScope mirroring Adv_vendors::baseWhere() (active=1 / soft_delete=0 / price>0), the #588 mechanism — it scopes at every nesting depth and for non-REST consumers too, which a per-endpoint allow-list cannot. Until #613 lands, this branch's allow-list is defence-in-depth on the Line endpoint only.

  • [4.121.0] Docs: docs/flows/admin/AD-17-lines-admin.md claimed "All endpoints require JWT authentication" and cited stale route line numbers — both corrected. docs/guides/rest-middleware.md documented the relations allow-list with a 'guest' scope key that resolveScope() can never produce and no 'default' key; copying that example would have produced a silently fail-open policy, so the example and the surrounding text now spell out the three real scope keys, why 'default' => [] is mandatory, and how bracketed params are handled. docs/api-guides/relations.md and docs/api-guides/response-scoping.md each described scope-based relation filtering as a general platform behavior; both now say it is opt-in per controller and, today, only Customer and Line declare it.

  • [4.121.0] No DB migration, no OpenAPI schema change, no language-key changes. A rest_api_versions.php entry is recorded under the per-task cadence.

  • [4.121.0] Check for overrides: easy_v4 and acropolis_v4 both carry a local stopgap patch to this same file/line applied ahead of this fix — drop it on the next upstream merge and take the upstream version instead of letting it conflict or double-apply. Any other fork with a local override of this view won't pick up the fix automatically and needs to re-apply the icon conditional by hand.

  • [4.121.0] UI Update: the orders-list icon now varies per row again (guest vs. registered) where every row previously showed the same icon — restores prior admin-facing behavior rather than introducing new behavior.

  • [4.121.0] Outbound payload type change: on shops running the default empty ordersPrefix, two outbound JSON payloads now carry the order reference as a JSON string rather than a JSON number (a shop with a configured prefix already sent strings). This is a correction, not a regression, but is worth knowing about if you have bespoke integration code or reconciliation tooling that type-checks these fields:

    • src/PaymentGateways/Klarna/Klarna.php:100 — merchant_reference1. Klarna's API documents this field as a string, so this moves toward spec.
    • The NBG e-Commerce (Ethniki EE) hosted-checkout browser config — built at ecommercen/checkout/controllers/Adv_checkout.php:1287-1288, emitted by application/views/main/layouts/checkout_complete/ethniki/ethniki_ee_process.php — order.id and order.reference. This now agrees with the server-side session for the same order, which src/PaymentGateways/NBG/NBGEEHelper.php:44 (beginNewSession(string $orderId, …)) already sent as a string.
  • [4.121.0] Check for overrides: Adv_order_model is commonly overridden in client forks (application/modules/eshop/models/Order_model.php extends Adv_order_model). The four changed methods — createSerial(), set_status(), getOrder(), get_records_customer_base() — have no signature change on any of them; this is pure line-addition inside existing method bodies (+26/-0), so it's not a BC break and carries no LSP/fatal risk on merge. A fork that hasn't overridden any of the four gets the fix automatically on merge: no conflict, no action needed. A fork that has overridden any of the four silently misses the fix — PHP method override means the parent's new lines never execute, and because the child's override lives in a separate file there is no merge conflict to surface it. That's the dangerous part: nothing will flag it, so you have to go check. Grep your application/modules/eshop/models/Order_model.php for those four method names, and where one is overridden, apply the matching coercion — the correct cast differs per method, so don't apply the same line everywhere:

    • createSerial() — cast $insertId to (string) unconditionally, as the method's first statement.
    • set_status() — cast $orderSerial to (string) only when it is not null; (string) null is '', which would turn WHERE order_serial IS NULL into WHERE order_serial = '' and update a different row set — on an UPDATE.
    • getOrder() — cast only past the existing empty/null guard, and don't trim, so a serial keeps matching exactly what it matches today.
    • get_records_customer_base() — cast only when isset($conditions['order_serial']), so a null still renders IS NULL.
    • If createSerial() is the only one you override, that cast alone is sufficient — it's the root fix and covers all 26 serial-keyed call sites.
  • [4.121.0] Check for overrides: a silent positional-argument shift. Advisable\Domains\Order\Order\WriteData::__construct() had a promoted constructor property removed (?string $cartContents, previously between metaData and remind). A fork constructing Order\WriteData positionally has every argument after metaData silently shift by one position, with no error anywhere. In this repo construction is entirely named-argument (fromArray() uses named args; there is no positional new WriteData( for this class), so the exposure is fork-only — and it is the most important item in this note precisely because it is silent: a fork with a positional constructor call will not fail loudly, it will just start writing the wrong value into the wrong field.

  • [4.121.0] Check for overrides: the REST contract. The Order write OpenAPI schema loses its cartContents property. A client sending cartContents / cart_contents in an order-write request body now has that field silently ignored (dropped, not rejected) instead of accepted.

  • [4.121.0] Check for overrides: informational only, no runtime effect. Order\Repository\Entity loses its @property string|null $cart_contents annotation. A fork reading $order->cart_contents was already reading a column that has never existed in the shipped schema — this just stops documenting it as if it did.

  • [4.121.0] Check for overrides:

    • New overridable surface: Adv_customer_model::changePassword(int $customerId, string $newPassword): bool (ecommercen/eshop/models/Adv_customer_model.php). This is additive — no BC break — and a fork that already overrides protected generateEncryptedValues() now gets a correct REST change-password for free, with no action required.
    • Collision risk: a fork that already declares its own changePassword() on Customer_model with a different signature will fatal on the upstream merge (the known #417 override-seam failure mode). Forks should grep application/modules/eshop/models/Customer_model.php for changePassword before merging this change.
  • [4.121.0] Support note, overriding tenants only: on a stock tenant the stored hash shape is unchanged by this fix. On a tenant with a custom hashing scheme, any customer who changed their password through this REST endpoint before this release still has an unauthenticatable stored hash — this fix does not repair already-written rows, so they need a password reset.

  • [4.121.0] No REST contract change — request/response shapes, status codes and OpenAPI attributes are all identical; a rest_api_versions.php entry was still added under the per-task cadence, carrying the operational note above for tenants that override the model's password hashing.

  • [4.121.0] Check for overrides: two constructor signatures changed (#596). Both are replacements, not additions, so a fork will fail loudly rather than silently — but neither is source-compatible.

    • Advisable\Rest\Cart\Controllers\Cart::__construct() — the 5th promoted parameter protected ProductCodeRepository $productCodeRepository is replaced by protected CartProductQuantityResolver $cartProductQuantityResolver (the parameter count is unchanged; nothing else in the class used the repository). A fork that subclasses Cart and overrides the constructor must update its own signature and parent::__construct() call, and a fork that reads $this->productCodeRepository from an overridden method will now hit an undefined property — inject the repository itself, or use the new resolver. A fork that copied cartProductQuantities() into its own override keeps working but keeps the per-line query too; it should delete the copy and call $this->cartProductQuantityResolver->resolve($items).
    • Advisable\Domains\Checkout\Gift\GiftMatcher::__construct() — the 2nd parameter ProductCodeRepository is replaced by CartProductQuantityResolver. A fork that constructs GiftMatcher by hand, aliases it to a Custom\ subclass, or overrides its constructor must update accordingly; autowired DI registrations need no change.
    • CartGiftPresenter::present() and CartGiftNearMissPresenter::present() are unchanged — same signature, same [productId => qty] input, same output.
  • [4.121.0] Deployment: delete cache/container.php (or let it rebuild) — the compiled container hard-codes GiftMatcher's previous constructor arguments.

  • [4.121.0] Behaviour change — rule-13 gift carts now report a LOWER cart_total_vat(). This is the headline, not a refactor. 563-checkout-vat-inclusive-totals.md documented exactly this divergence and deliberately chose the order expression, "because that is the one that has to equal the charge"; this change closes it from the other side, so the storefront/coupon-gate expression now adopts the rule-13-adjusted $paidQty that excludes the cheapest-free unit, and bundle pricing likewise. Two customer-visible numbers move down on bundle / rule-13 carts, both accepted:

    • the ESHOP.MIN_ORDER_AMOUNT gate (application/views/main/layouts/cart/cart.php:70) — a gift or bundle cart that clears the minimum today may stop clearing it; the parsed total is what is actually charged, so this is the correct basis;
    • the Google Analytics ecommerce value (application/views/production/google_header.php:108, production-only, gated on GOOGLE.IS_ENABLED) — reported revenue drops to the real figure. Re-baseline any dashboard or acceptance fixture pinning either. There is no free-shipping impact: the only two shipping helpers that ever read cart_total_vat() — transfer_cost() and delivery_cost() (ecommercen/helpers/eshop_helper.php:43, :78) — are dead. Their sole references repo-wide are two commented-out lines in application/views/admin/orders/edit.php:139 and :144. Every live shipping path, storefront included, goes through transfer_cost_admin() / delivery_cost_admin() instead, and those are already fed the parsed $cart['cart_total_vat'] — see Adv_order_model::frontOrderCostTerms() (:731 / :742) and parseCartForCheckout() (:842 / :853) for the storefront, adminOrderCostTerms() (:1858 / :1872) for admin. Despite the _admin suffix, those are the storefront's helpers too.
  • [4.121.0] UI Update: in the admin order editor, a coupon's discount is now computed against the items subtotal rather than the delivery/packaging-inflated grand total, and is no longer deducted twice on recalculation. The discount shown for an existing coupon on an order being edited or cloned will change wherever delivery, transport or gift packaging is priced.

  • [4.121.0] Check for overrides: application/modules/eshop/models/Order_model.php — a fork overriding baseParseCartContents() now drives every cart_total_vat() caller, including two views and the storefront coupon gate, not just the cart resource and checkout. Review the override against that wider blast radius. Such a fork must also grep its own override for cart_total_vat: a fork-only call to the helper from inside baseParseCartContents() (or anything it reaches) would now be an infinite recursion cycle. Core has no such cycle — verified across the full transitive subtree. Separately, the helper now loads eshop/order_model on storefront pages that previously never constructed it, so a fork whose Order_model::__construct() has side effects will see them earlier and more often.

  • [4.121.0] Check for overrides: application/modules/eshop/libraries/VatForOrder.php — its vat() is now on the memo key path. A fork overriding invoiceVat() / receiptVat() is handled correctly by design (the key probes behaviour, not state), subject to two residual limits, both dormant on core and both fixed by widening the probe. The probe covers only the rates [0.0, 6.0, 13.0, 24.0], so a fork whose vat() diverges only at an unprobed rate gets a stale memo hit; and it never varies vat()'s second parameter $isDigital (AdvVatForOrder.php:55), always passing the default false, so a fork whose override branches on $isDigital could get a stale hit for a digital-vs-physical cart within one request. Core never exercises either case — every core call site (Adv_order_model.php:552, Adv_product_parser_model.php:131, :475) calls vat() single-arg. vat() must also stay side-effect-free — it is now called four extra times per request.

  • [4.121.0] Check for overrides / BC: the global cart_total() helper was deleted. It had no callers in this repo, but it was a global function, so a client fork could in principle call it. A fork that does should switch to cart_total_vat() — which is what cart_total()'s own docblock said it was equivalent to — and should expect the corrected, bundle- and rule-13-aware value.

  • [4.121.0] Known remaining wrinkle — Adv_orders_admin.php:1460 (before this change; now :1467).$cart['cart_total'] = $orderData['cartTotal'] is left as-is and is still doubly wrong: cart_total is the NET key per the basis table ratified in 563-checkout-vat-inclusive-totals.md, while cartTotal is a gross grand total. It is harmless in this response path — calculateCart() only copies it into old_cart_total (Adv_coupons_model.php:1010), which nothing here consumes — so correcting it is a separate, unfiled question rather than a widening of this fix.

  • [4.121.0] Out of scope, deliberately. Two adjacent gaps in the admin path are untouched. The browser-computed cartItemsTotal itself (footer_js.php:2559, mutated by assets/admin/js/order-gifts-block.js:281) is still derived client-side rather than from the server's parse. And Adv_order_model::createAdminFakeCart() (:1691-1791), which builds the Adv_orders_admin::$fakeCart fed to isValidCoupon() at Adv_orders_admin.php:2639 and :2676, is rule-13-aware (:1725) but still bundle-blind — it calls getLiveProductsParsed() and never applyBundlePricingToCartLiveData(), whose only call site in the model is :543 inside baseParseCartContents(). So the admin-side coupon gate retains a narrower version of the same divergence on bundle carts.

  • [4.121.0] Memo lifetime caveat. A function-local static is per-process, which is correct under PHP-FPM / mod_php (one request per process) but would persist across requests under a long-lived worker (RoadRunner / Swoole / FrankenPHP). The CI super-object and the session share that assumption, so this is stated rather than designed around. Note also that memoising means baseParseCartContents()' incidental mutations of shared model state — Adv_product_parser_model::$products is re-initialized and repopulated by each get() pass, twice per parse — stop happening on repeat calls. All of that state is protected with no external reader, so the risk is low, but it is a real delta rather than a pure no-op.

  • [4.121.0] Companion to 545-myinput-inputstream-memoisation.md, the other per-request memoisation fix in this release. Its lesson is applied here: the memo guard is written as a plain if (array_key_exists($key, $memo)) { return $memo[$key]; }, not the terser isset($x) or $x = ... one-liner whose operator precedence is what #545 had to repair.

  • [4.121.0] Check for overrides — as shipped this is inert on almost every fork. Of the 34 client forks carrying a settings controller, 31 ship their own application/views/admin/settings/only_advisable.php (so staff never see the checkbox and cannot enable the capability at all) and 30 ship their own application/views/admin/settings/xml_feeds_settings.php with 14+ custom-price blocks and no guard (so the controls stay fully visible and submittable regardless of the switch). The feature is fully effective only in the one deployment no customer runs. That is a rollout gap to schedule, not a code defect — but read this as "the forks must be synced before the switch means anything", not as an optional view-parity audit.

    • Controller overrides — six forks, where a views-only sync is UNSAFE. Adv_settings::only_advisable() is overridden by Flioukas, with no parent:: call at all, so the ON→OFF reset never runs there. Adv_settings::xml_feeds_settings() is overridden by Discount, Gea, Heals, Pharm16 and Smile. On these six, refreshing only the views produces a switch that looks like it works and does not. Smile is the clearest case (Smile/application/modules/settings/controllers/Settings.php): its override duplicates the entire custom-price persist path — 14 flag writes, 14 modifier writes, two fork-only feeds (CUSTOM_PRICE_RETAIL, CUSTOM_PRICE_API_SEARCH) and its own updateXmlFeedCustomPriceModifier() — with no reference to CUSTOM_PRICE_ENABLED anywhere. Sync Smile's only_advisable.php alone and the switch becomes cosmetic: the inherited only_advisable() resets the shared flags and staff see "off", but a merchant admin can re-tick a per-feed custom-price box and save, because Smile's own save path does not know the switch exists. Adding a parent:: call cannot close that — the upstream guard's whole job is to not write, and the fork's own writes still run. Each of the six needs its controller override read and gated, not just its view refreshed. A switch that appears to work but doesn't is worse than no switch, so whoever performs the view sync must be handed this list with it.
    • Fork-only feed keys fall outside the reset. The reset covers main's 14 CUSTOM_PRICE_&lt;FEED> labels only. Heals and Pharm16 additionally carry CUSTOM_PRICE_EFOOD plus an unsuffixed CUSTOM_PRICE / CUSTOM_PRICE_MODIFIER pair; Smile carries the two named above. On those forks, those feeds keep publishing modified prices after the switch goes off — the exact failure this feature exists to prevent. Any fork sync must extend the reset to that fork's own feed keys.
  • [4.121.0] Not immediate for published feeds — a known operational characteristic, not introduced by this change. Turning the switch off can take up to 24 hours to reach a public feed request. Nothing in ecommercen/feeds/ calls registry->live() (AdvXml extends Base_c, not Adv_admin_controller), Registry::setValue() never busts the L2 cache, and cache_l2_default_expires defaults to 86400 — forced to -1 only in development — so a feed keeps serving cached registry values until the entry expires. This is systemic: every IS_ENABLED_* / IS_PROTECTED_* toggle on the same feeds behaves the same way. It is tracked separately and deliberately not addressed here. Staff flipping the switch off for an urgent reason should expect the delay.

  • [4.121.0] New installs only: a freshly seeded shop has XML custom pricing off and needs an Advisable staff member to enable it on settings/only_advisable before any per-feed modifier takes effect. Existing installs are unaffected — the absent-row default is ON.

  • [4.121.0] No DB migration, no composer install, no npm run all-production. The lang keys are runtime-loaded PHP arrays and the two views are server-rendered CI3 templates, not Vue — no build step is involved.

  • [4.121.0] Check for overrides: Settings::googleValidation() / Settings::googleRender() — a client overriding either method must re-apply the new google_recaptcha_v3_* validation rules (including callback_recaptchaV3SecretIsStorable and its set_message) and the GoogleRecaptchaService::normaliseThreshold() call that renders the effective threshold, or the reCAPTCHA v3 fields will not validate or display their default.

  • [4.121.0] Check for overrides: any client trait/controller overriding getCaptchaServerKey() keeps working — it is still the single source of the v2 siteverify secret, now passed into GoogleRecaptchaService by GoogleRecaptchaTrait::recaptchaService() instead of being read from the Registry by the service. A client that wants to supply its own v3 secret should override the new parallel accessor getCaptchaV3ServerKey().

  • [4.121.0] Check for overrides: a client overriding GoogleRecaptchaTrait::recaptchaService() must not re-add ->withName('recaptcha') at the call site — the service now names its own log channel and resolves the logger lazily, and an eager di() call there re-introduces a DI dependency on the storefront render path.

  • [4.121.0] Check for overrides: a client fork overriding GoogleRecaptchaTrait::getCaptchaClientMarkup(), getCaptchaJavascript(), or setCaptchaValidationRule() with their old signatures won't fatal — the new $withInlineStepUp/$action parameters are optional — but it silently keeps v2-only behaviour and never activates v3, even with v3 keys configured. Adopting v3 requires the override to take the new parameter and branch on it: $withInlineStepUp drives the inline v2 step-up container on the product page; $action binds the per-form v3 action (contact_form, return_form, waiting_list) verified server-side.

  • [4.121.0] Check for overrides: Products::renderWaitingList() — a client overriding this method must add the $this->setCaptchaAction('waiting_list') call before rendering the captcha markup. Without it the product page mints a v3 token for the default submit action while the waiting-list endpoint verifies against waiting_list, and every waiting-list submission fails closed.

  • [4.121.0] Check for overrides: a client overriding CaptchaTrait or CodeigniterCaptchaTrait wholesale must carry the new no-op setCaptchaAction(string $action): void, which Products::renderWaitingList() now calls unconditionally.

  • [4.121.0] Check for overrides: a client fork that supplies its own captcha provider trait (rather than using upstream CaptchaTrait, CodeigniterCaptchaTrait or GoogleRecaptchaTrait) must implement the full provider contract: setCaptchaValidationRule(), captchaCheck(), setCaptchaAction(string $action): void and captchaStepUpAvailable(): bool. Products::renderWaitingList() and Waiting_list::add() now call the last two unconditionally — the previous defensive method_exists() guard on captchaStepUpAvailable() has been removed.

  • [4.121.0] Check for overrides: a client fork with its own application/views/**/footer/footer_js.php override carrying a waiting-list handler keeps the pre-v3 handler, and its waiting list will fail under v3. It must port the v3 token resolver, the inline v2 step-up reveal, and the captcha_step_up branch — or drop the override. The same applies to a fork overriding the main theme product views that renders the waiting-list block without $captchaMarkup.

  • [4.121.0] Check for overrides:

    • AdvGiftCardOrdersModel::acceptGiftCard() (ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php) — signature changed from void to bool. A fork that overrides this method, or copies its body, keeps the unguarded coupon-first shape and the money leak; it must adopt claim-before-issue.
    • New overridable surface: protected acceptGiftCardFrom($orderId, array $allowedFrom): bool and acceptGiftCardManually($orderId): bool on AdvGiftCardOrdersModel.
    • A fork overriding AdvGiftCardAdminListing::acceptGiftCard() must route to acceptGiftCardManually() and handle the false → 409 case, or staff lose manual-accept for cancelled orders.
    • Any fork subclass or test double typing acceptGiftCard(): void must widen to bool.
    • AdvGiftCardPage::acceptGiftCard() (ecommercen/gift_cards/controllers/AdvGiftCardPage.php:1109) — the thin protected seam that all ~16 storefront entry points funnel through, signature changed from void to bool so it can pass the model's claim result up to its callers. A fork override declared : void will fatal on an LSP return-type variance error as soon as the parent declares : bool; a fork override with no return type at all is quieter but worse — it silently returns null (falsy), which suppresses acceptPostActions() on every accept, not just refused ones. No such override exists in this repo — application/modules/gift_cards/controllers/Gift_card_page.php is an empty pass-through.
    • That widening is what let successView() and the XPay 'accept' branch start calling acceptPostActions() only when the accept actually claimed the row, instead of unconditionally as before. Harmless here, since acceptPostActions() is itself an empty pass-through, but a fork that overrides it to send an email or SMS would have re-sent it on a refused (no-op) accept — e.g. a customer refreshing an already-completed bank return URL. No customer-visible change: renderSuccess() still runs on the refused path exactly as before, mirroring the existing piraeusSuccessAction() 'render_success' branch.
  • [4.121.0] UI Update: assets/admin/js/giftCards/components/GiftCardOrders.vue — the Accept and Cancel actions previously had no error handling: a non-2xx response silently failed with no toast and a stale grid. Both now show an error toast (server message when present, otherwise the existing generic retry string) and refresh the grid on failure, so the row's true status — including the new 409 on a stale double-click — becomes visible.

  • [4.121.0] UI Update: admin → payment settings screen. The storefront payways panel (METHODS/PAYWAY) and the gift-card payways panel (METHODS/PAYWAY_GIFT_CARDS) now redisplay the merchant's saved payment-method order instead of the fixed default order. No fields, routes, or saved values changed shape — only the redisplay order.

  • [4.121.0] Check for overrides: application/views/admin/settings/payment_settings.php is a view template, not a class a fork subclasses — a fork carrying its own wholesale copy of this file will not pick up this fix by merging upstream and must apply the same two-line change locally. In both the storefront panel (~line 70) and the gift-card panel (~line 213), change:

    php
    $selected_payways = $selected_payways_match + $payway_methods_keys;

    to:

    php
    $selected_payways = array_merge($payway_methods_keys, $selected_payways_match);

    This issue was filed from a client fork (Smile Pharmacy) that had already independently patched its own copy of this exact view — direct evidence other forks may carry local overrides of payment_settings.php that won't inherit this fix.

  • [4.121.0] No behaviour change. This is pure statement ordering, not a functional change. Nothing downstream in indexExtras() reads hits for the current product: fixSelect() (Adv_products.php:700-743) does not include hits in its column list, so $this->render['productData']->hits never exists and no view can render it. updateProductHits() itself is a pure pass-through to $this->product_model->updateHits(...) — it sets no $this->render[...] key, writes no session state, and returns nothing, so there is no side effect for other statements in the method to depend on. One accepted edge case: a product listed in its own shop_related_products row (a data error, not a supported configuration) can shift its own rank by one position in getRelatedProductsLp()'s ORDER BY shop_product.hits DESC, since the hit increment used to land before that read and now lands after it.

  • [4.121.0] Check for overrides. No protected method signature changed on updateProductHits() or indexExtras(), so a client fork inherits this automatically — nothing to update on merge for a fork that hasn't touched either method. However, a client fork that overrides indexExtras() itself keeps its own statement ordering unchanged and does not receive this reorder — because the override lives in a separate file, there is no merge conflict to surface that, so if a fork has such an override it needs to be checked and reordered by hand to get the same benefit.

  • [4.121.0] This is not a performance fix — do not read it as one. Issue #471's own verification measured causal_reads: none (post-write SELECTs are not pinned to the DB primary today) and replicas serving 98.2% of SELECTs in steady state, concluding "there is no routing bug." This change has no measurable effect on the current setup. Its value is purely as a prerequisite: it is a "writes-last" discipline that keeps a future causal_reads rollout from pinning the whole read-heavy render pipeline to the primary just because a write happened early in the method.

  • [4.121.0] No schema change, no REST contract change, no config key, no UI change.

  • [4.121.0] Check for overrides: a fork with a Custom\ override of this Validator (or a subclass of it) does not inherit the new enum check and keeps accepting arbitrary channels. The new private messageTypeChannelError() is a private class member, so no LSP/inheritance conflict — but a fork that copied the whole class body will diverge silently and needs to re-apply the check by hand.

  • [4.121.0] UI Update: a backend client sending a lowercase channel, or a message category in messageType, now gets a 422 where it previously got a 200. Such a request was writing a malformed row — the fix is to send the category in type and the channel in messageType, not to work around the 422.

  • [4.121.0] No schema/shape change and no OpenAPI diff — the messageType property already advertised EMAIL/SMS; a rest_api_versions.php entry was still added under the per-task cadence.

  • [4.121.0] Check for overrides (informational only — no signature changed): no production code changed in this delivery, so no protected method signature moved. A client fork that overrides MY_Session does not inherit these tests, and if its override changes isRouteExcluded() / normalizeRouteElements() semantics the new assertions encode main-repo behaviour only.

  • [4.121.0] No schema, REST contract, config-key, or UI change.

  • [4.121.0] Test-only change: closes a test gap and adds no runtime behaviour. There is nothing for a client to action on upgrade.

  • [4.121.0] Forks that dispatch ask_us_email — this merge DELETES your template SILENTLY. A fork that renders main/mail/ask_us_email.php (its emailViews.json templateFolder is main/mail, with a fork-added layout key + mailer method, rather than owning a copy under its own template folder) loses the file on merge with NO conflict and NO prompt — git only conflicts on a path the fork modified, and this one is unmodified there. The failure surfaces at runtime: the next dispatch fatals with "Unable to load the requested file". At least one client is known to be in this position, so this note is not hypothetical.

    • To keep it, make it fork-owned — during merge resolution, not after. Restore from the pre-merge tip and commit it in the fork: git checkout &lt;pre-merge-sha> -- application/views/main/mail/ask_us_email.php, then git add + commit. Upstream no longer carries that path, so it will never conflict again and no future merge can remove it. Preferably move it under the fork's own template folder (application/views/&lt;client>/mail/) and point the fork's emailViews.json layout key at it — that is where a fork-only template belongs.
    • The admin preview entry is gone for every fork. The list is a hard-coded array inline in AdvEmailViewer::index() with no override seam (unlike sampleEmailData(), which has three), so restoring the preview means overriding index() and copying all 20 remaining entries. A fork that only ever saw this template in the preview page and never dispatched it needs no action — that is the intended outcome.
    • sampleEmailData() keys retained on purpose. full_name, email and message are still set, so a fork's restored copy renders in the preview without further edits.
  • [4.121.0] No DB migration, no composer install, no container rebuild, no npm run all-production — two deleted server-rendered CI3 templates and one removed array element.

  • [4.121.0] REQUIRES npm run all-production: two admin .vue sources changed, so the admin bundles must be rebuilt for the fixes to reach the browser.

    • assets/admin/js/giftCards/components/GiftCardOrders.vue — Accept/Cancel error toasts and grid refresh on failure.
    • the admin order-transporters block — public/ui/admin/dist/order-transporters-block.js.
  • [4.121.0] Behaviour note: order edit/repeat now issue one additional POST /&lt;lang>/api/transporters/getAvailableTransporters during page load. It is the same call the page already made on any subsequent interaction, and it is skipped entirely when no transporter is preselected.

  • [4.121.0] Availability is still enforced. The added calculateCosts() runs the normal compareTransporters() pass, so a preselected carrier that is no longer available for the order's address is still cleared and must be re-picked — the fix publishes the selection, it does not force it past the availability rules.

  • [4.121.0] Merge conflict resolution (assets/admin/js/order/AdminOrderTransporters.vue): the upstream change is only the guarded calculateCosts() block inserted at the end of mounted(), immediately before the closing await this.getExternalTransporterCost(). Keep all of a fork's own mounted() additions and take this block alongside them. Two ordering constraints matter and a resolution that breaks either reintroduces the bug:

    • it must come after this.selectedTransporter = this.clonedTransporter, otherwise there is nothing to recalculate for;
    • the if (this.selectedTransporter) guard must be kept, otherwise order create pays for a redundant request on every load.
    • A fork that moved the listener registrations above the first await should re-check this hunk by hand: with the listeners bound early the page-ready notifyTransporters is no longer lost, but it can then fire before initTransporters() resolves, and compareTransporters() against an empty availableTransporters will clear the preselection instead.
  • [4.121.0] UI Update: application/views/admin/blog/blogs/update.php — the Builder &lt;select> in the "Article details" block is renamed from builder_block_id_&lt;lang>} to builder_block_id_&lt;lang>. Any fork-side JS or test that targets the old malformed name must be updated.

  • [4.121.0] Data note: the fix stops further loss but does not restore already-detached blocks. Articles edited since 6fb7ef5837 have blog_mui.builder_block_id = NULL and need the Builder re-selected by hand. To find them: SELECT blog_id, lang FROM blog_mui WHERE builder_block_id IS NULL;

  • [4.121.0] Merge conflict resolution (application/views/admin/blog/blogs/update.php): this view is one of the most commonly customized admin templates, so forks routinely carry local edits to it and this hunk may land in a conflict. Resolve as follows:

    • The upstream change is only the two builder_block_id_... strings inside the &lt;?php if ($builderEnabled) : ?> block. Keep all of the fork's surrounding markup, extra fields, layout classes and loop-variable naming; take upstream's braced form for those two strings alone.
    • If the fork renamed the loop variable (some forks still use $l_abbr), keep the fork's variable name and just add the braces: "builder_block_id_{$l_abbr}". The variable's name is irrelevant — the missing { is the whole defect.
    • Post-merge verification, which must return no matches:
      bash
      grep -rn 'builder_block_id_\$[A-Za-z_][A-Za-z0-9_]*}' application/ custom/
    • A fork that keeps its own copy of this screen under a client-owned template path (a template_view_admin other than admin) will not receive the fix through the merge — apply the same one-character correction to that copy too, and let the grep above cover it.