Skip to content

Allow patching a multiselect to empty - #2018

Closed
xIrusux wants to merge 67 commits into
2026.2from
fix-multiselect-patch-replace-null
Closed

Allow patching a multiselect to empty#2018
xIrusux wants to merge 67 commits into
2026.2from
fix-multiselect-patch-replace-null

Conversation

@xIrusux

@xIrusux xIrusux commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Because clearing a multiselect through a merge is a legal operation the core adapter's type signature forbade.

What the merge sent: object 333's color is empty; you chose to pull that side over, so the merger saved color: { action: 'replace', data: null } — "replace the target's value with nothing", i.e. clear the field. That's the correct payload for this merge, not a frontend bug.

Where it exploded: MultiSelectAdapter::handlePatch handles replace by returning the payload's data verbatim — but its return type was declared array, so data: null throws a TypeError and the whole PATCH 500s (MultiSelectAdapter.php:66).

Why widening to ?array is the right fix, not a workaround: the caller already supports null — PatchService::patchEditableData passes the adapter's return straight into $element->setValue($key, $value) with no null check, and setValue(key, null) is Pimcore's normal "clear this field". The add/remove paths in the same adapter also cope with null existing values. Only the replace path's signature was narrower than its own contract. One character (?) restores it.

Verified

  • Reproduced via the object merger (embedded in the backend-power-tools compare & merge wizard): merging an object with an empty multiselect over one with values 500ed; with this change the PATCH clears the field.
  • Regression test added (MultiSelectAdapterTest, following the NumericRangeAdapterTest pattern): replace with data: null returns null, replace with values returns them, non-array input returns null. 3/3 green locally via vendor/bin/codecept run Unit.

🤖 Generated with Claude Code

Pimcore Deployments Bot and others added 30 commits July 3, 2026 11:14
🔄 synced file(s) with pimcore/platform-version
Co-authored-by: Pimcore Deployments Bot <pimcore-deployments@pimcore.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Rename new-docs.yml to docs.yml

* Remove new-docs.yml after rename to docs.yml

* Rename new-static-analysis.yaml to static-analysis.yaml

* Remove new-static-analysis.yaml after rename to static-analysis.yaml
* [Workflow] Add WorkflowFilter to scope element grids by workflow place (#1938)

* [Workflow] Exclude folders from workflow element queries (#1930)

* [Workflow] exclude folders from workflow element queries

Folders share their element's ctype (asset/object/document) in
element_workflow_state, so fetchByWorkflowState listed asset folders
alongside real assets in the workflow pending-items widget and the
workflow_get_elements click-through list (pimcore/studio-dashboards-bundle#301).

Filter out folder subtypes in SQL via the already-present assets/objects/
documents joins. The IS NULL guard per table is required: a plain
"type != 'folder'" would drop every non-matching LEFT JOIN row (NULL type)
through three-valued logic, emptying the result. Orphaned states (element
deleted) are preserved unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address static-analysis finding: drop unused leftJoin callback params

Verify the join count via expects(exactly(3)) instead of a capturing
callback with unused $fromAlias/$join parameters (Codacy/PHPMD
UnusedFormalParameter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [Workflow] add WorkflowFilter to scope element grids by workflow place

New search-index filter (type `workflow`, key=workflow name, value=place) that
resolves the matching element ids via WorkflowElementsRepository (folders already
excluded) and applies the standard searchByIds modifier. Lets the native element
listing be filtered to a workflow state server-side — the basis for the state
distribution donut drill-down opening the native grid instead of a bespoke list.

Element type is derived from the query (data-object / asset / document). Empty id
set matches nothing; a MAX_IDS ceiling guards the OpenSearch terms limit (logged).
Registered with the pimcore.studio_backend.search_index.filter tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [Workflow] document the workflow grid filter

Add the `workflow` column filter to the Grid architecture docs (filters
table + example): key = workflow name, value = place (omit/null = all
states), folders excluded, ids resolved server-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Apply php-cs-fixer changes

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…public/default

🔄 synced file(s) with pimcore/workflows-collection-public
Co-Authored-By: Claude <noreply@anthropic.com>
Static analysis needs no secrets: the shared reusable has no cache:clear/
kernel-boot step, phpstan.neon sets no containerXmlPath, composer.json has
no auto-scripts, and the repo is public (deps from public Packagist). So
pull_request_target only adds risk — it currently checks out fork PR head
with secrets in scope, which actions/checkout now refuses (pwn-request
guardrail), breaking fork PRs at Checkout code.

Switching to pull_request runs fork PRs safely without secrets and fixes
that failure with no security gate needed.
…public/default

🔄 synced file(s) with pimcore/workflows-collection-public
…ull_request

Run static analysis on pull_request instead of pull_request_target
* Add PR Guardrails trigger workflow (platform-version#226)

* Pass only the three guardrail tokens instead of secrets: inherit
Add a `pimcore_studio_backend.translations.auto_create_missing_keys`
config flag (default: true) and expose it to the frontend via
SystemSettingsProvider as `auto_create_translations`.

This lets projects that maintain the `studio` translation domain purely
through YAML files opt out of the frontend-driven auto-creation of
missing keys, which otherwise pollutes the `translations_studio` table
with non-translatable labels (numeric values, select-field choices).

The flag defaults to true, preserving the existing behaviour. The
frontend gate that consumes this flag ships separately in
studio-ui-bundle. Manual creation via the Translations editor is
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-translation-keys

Allow disabling auto-create of missing translation keys
…public/default

🔄 synced file(s) with pimcore/workflows-collection-public
…em columns (#1966)

* [Grid] Add User Modification and User Owner as selectable object system columns

Introduces a new `system.user` grid column type (definition + resolver) and
exposes `userModification` / `userOwner` as selectable system columns for data
objects. The resolver returns the raw user id; the Studio UI resolves the
username for display.

Refs pimcore/studio-ui-bundle#1956

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [Grid] Honor isFilterable() for system columns; keep user columns non-filterable

SystemFieldCollector::overrideFilterable() used $definition->isSortable() in its
default branch, so a definition's isFilterable() was ignored. This advertised the
new userModification/userOwner columns as filterable despite UserDefinition
declaring them non-filterable. Use isFilterable() instead — every existing data
object system column has isSortable() === isFilterable(), so only the user columns
change (sortable, not filterable), matching the intended contract.

Adds a UserDefinition unit test locking the sortable/non-filterable flags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix formatting of exception documentation in UserResolver

* Fix formatting and add missing docblock for resolve method

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Expose version coauthor fields in version listing schema

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsEa2MTm2gRPyGEdHGnnoF

* Accept coauthor fields on element save and patch payloads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsEa2MTm2gRPyGEdHGnnoF

* Allow setting version coauthor via PUT /versions/{id}

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsEa2MTm2gRPyGEdHGnnoF

* Treat empty strings as clear when updating version coauthor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsEa2MTm2gRPyGEdHGnnoF

* Require pimcore/pimcore ^2026.3 for version coauthor support

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsEa2MTm2gRPyGEdHGnnoF

* Address Codacy findings on coauthor changes

Drop the redundant "(e.g. agent)" concat from the coauthorType OpenAPI
descriptions (the Property already carries example: 'agent') and shorten
the previousCoauthorContext locals to coauthorSnapshot, keeping both the
line length and PHPMD's variable-length rule happy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsEa2MTm2gRPyGEdHGnnoF

* Add UpdateService coauthor context tests

Cover UpdateService::update() coauthor-context wiring end to end through
the public API with a real Pimcore\Model\Version\CoauthorContext: payload
keys activate the context only for the duration of the save, empty/partial
keys never activate it, an outer context is overridden during save and
restored exactly afterwards, and the context is still restored when save()
throws.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsEa2MTm2gRPyGEdHGnnoF

* Resolve merge conflicts and address review feedback

The merge of 2026.x was pushed with unresolved conflict markers in
UpdateService and PatchService, leaving both classes unparsable.

- Resolve both conflicts keeping all additions from either side. In
  patchElement() the permission checks run before the coauthor context is
  set up, so a ForbiddenException cannot leave context state behind.
- Replace the snapshot/restore helpers duplicated across both services
  with the core CoauthorContextInterface::withCoauthor() scope helper,
  extracted into CoauthorContextTrait as the single owner of payload
  coauthor handling.
- Reject coauthorType/coauthor values exceeding the widths of the core
  versions columns (50/255) instead of letting the save truncate them:
  a 422 on the update/patch payloads and a Length constraint on
  PUT /versions/{id}. The limits are declared once in VersionCoauthor and
  surfaced in OpenAPI via the new CoauthorType/Coauthor property
  attributes, which also de-duplicate the five request bodies.
- Cover the patch path, the length limits and the version update
  parameter, which previously had no tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsEa2MTm2gRPyGEdHGnnoF

* Move payload coauthor handling into a dedicated service

Injecting the coauthor context plus a trait pushed PatchService to 21
class dependencies (php:S1200, max 20). An Element service replaces both,
so each caller depends on one collaborator instead of two and the class
drops back under the limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsEa2MTm2gRPyGEdHGnnoF

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: fashxp <8792145+fashxp@users.noreply.github.com>
* [Asset List] Add file size field filter (#705)

Make the file-size system column filterable and add a dedicated FileSizeFilter
search-index handler. Following the quantity-value convention, the handler reads
the client value plus unit (KB/MB/GB), converts it to bytes and applies it as a
numeric range on the `fileSize` field. The "is" setting matches a one-unit-wide
band (mirroring the datetime roundToDay behaviour), because file size is stored
byte-precise and an exact match would practically never hit.

Co-Authored-By: Claude <noreply@anthropic.com>

* [Asset List] Tolerate a single "between" bound in the file size filter (#705)

Treat a "between" filter with only one bound set as an open-ended range instead
of throwing, matching the numeric filter's leniency.

Co-Authored-By: Claude <noreply@anthropic.com>

* [Asset List] Fix file size "is" band boundaries and add filter tests (#705)

- filterNumberRange bounds are exclusive (gt/lt), so widen the "is" band by one
  byte on each side; otherwise a file of exactly N units never matched "is N"
- Add FileSizeFilterTest covering KB/MB/GB conversion, all four settings, the
  inclusive "is" band boundaries, single-bound "between", and malformed
  unit/value payloads

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
* Cover MCP endpoints with the general Studio API rate limiter

RateLimitSubscriber guarded on isStudioBackendPath($path, $urlPrefix), where
url_prefix defaults to /pimcore-studio/api. The MCP firewall serves
/pimcore-mcp/, a separate path space, so no MCP endpoint was covered by the
studio_api_general limiter at all - neither the Studio MCP routes nor the
Pimcore Agent bundle's /pimcore-mcp/agent/{group}.

Adds an explicit prefix branch. The prefix is matched with a trailing slash so
sibling routes that merely start with the same characters are not swallowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Defer PAT user lookup into the UserBadge loader

authenticate() resolved the username, loaded the Pimcore user, validated it and
only then built the UserBadge - throwing at every failure step. Because
AuthenticatorManager::executeAuthenticator() dispatches CheckPassportEvent only
after authenticate() returns, an authenticator that throws never produces a
passport, so LoginThrottlingListener::checkPassport() (priority 2080) can never
block it. Only LoginFailureEvent still fires, which means failures would fill
the limiter while nothing is ever rejected - and the sole party who can be
locked out is the holder of a valid credential.

resolveUsername() reads an in-memory token map, so the identifier is available
with no database work. Building the badge first and moving the lookup into its
loader makes the standard firewall throttling usable, and as a side effect the
database is no longer touched during authenticate().

Credentials that resolve to no user share one constant identifier: a per-token
identifier would hand every guess its own local bucket, leaving only the global
per-IP tier doing any work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Defer MCP access token lookup into the UserBadge loader

Same root cause as the preceding PAT change: authenticate() validated the token
and threw on failure, so no passport was ever produced and
LoginThrottlingListener::checkPassport() could not block the request.

validate() is itself the lookup, so unlike PAT no real identifier exists before
it runs; the placeholder identifier is used throughout and the global per-IP
limiter tier does the work here.

The _mcp_token_reference binding moves into the badge loader with the lookup.
It still lands before the controller - Symfony resolves the badge during
CheckPassportEvent - and is now set only for a token that actually validated,
which a new test pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Answer throttled MCP clients with 429 and Retry-After

login_throttling raises TooManyLoginAttemptsAuthenticationException, an ordinary
AuthenticationException, so the firewall entry point would render it as a bare
401 with no Retry-After - telling an MCP client its credential is wrong when the
truth is "back off". MCP clients are programs that act on Retry-After.

A shared trait maps it to 429 and converts the exception's threshold, which is
reported in minutes, into seconds. Ordinary failures still return null, so the
existing fall-through to the next authenticator on the firewall is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Throttle failed MCP authentication attempts

Enables Symfony's standard login_throttling on the pimcore_mcp firewall: 5
failures per 5 minutes per identifier+IP, plus an automatically derived global
limiter at 25 per 5 minutes per IP. Until now the MCP firewall had no
brute-force protection of any kind, and the general Studio API limiter did not
reach it either.

Deliberately sequenced after the authenticator restructure. Enabled against
authenticators that throw inside authenticate(), throttling would have been
worse than absent: LoginFailureEvent fires and fills the limiter, but
CheckPassportEvent never does, so guesses are never blocked while the holder of
a valid credential is the only party who can be locked out.

mcp_firewall_settings is consumed by host applications as
`pimcore_mcp: '%pimcore_studio_backend.mcp_firewall_settings%'`, so this reaches
every installation without an application-side change - including the Pimcore
Agent bundle's /pimcore-mcp/agent/{group} endpoints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Document MCP authentication throttling

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Note that blocked MCP requests still count against the limiter

Observed during end-to-end verification: a throttled request still reaches
LoginFailureEvent, so onFailedLogin() consumes another token. A client that
polls while blocked therefore keeps its own window full indefinitely. This is
stock Symfony behaviour and desirable against an attacker, but clients need to
know to honour Retry-After rather than retry-loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Key dynamic MCP tokens per token instead of one shared bucket

Review finding: every dynamic token, valid ones included, entered throttling
under the same placeholder identifier, so five wrong pmcp_ guesses exhausted the
local identifier+IP bucket and the next legitimate dynamic token from that IP was
rejected before validation. The earlier end-to-end check missed this because it
used a static PAT, which resolves to a real username and therefore has its own
bucket.

Dynamic tokens now key on a non-secret digest of the token itself. Repeating one
wrong token is still caught by the local tier; sweeping distinct tokens now lands
in a fresh local bucket each time and is bounded by the global per-IP tier, which
is the looser but correct trade - a guesser can no longer evict a legitimate
holder. Verified end-to-end: 6 distinct guesses no longer lock out at 5, the same
token repeated 6 times still yields 429, and a valid credential still passes.

Adds an integration-style regression driving the real LoginThrottlingListener
against the real DefaultLoginRateLimiter, which reproduces the original defect.

Also corrects two documentation claims:
  - success does NOT reset the budget. LoginThrottlingListener skips the reset
    for peekable limiters, and DefaultLoginRateLimiter is peekable, so failures
    decay only when the fixed window rolls over.
  - the global tier is per-IP and shared, so a sustained attack can still
    throttle other clients behind the same address.

INVALID_IDENTIFIER returns to private now that no other class consumes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Correct the MCP firewall override instructions

The documented override told integrators to write login_throttling under
security.firewalls.pimcore_mcp. That firewall is declared as a whole-value
parameter substitution - pimcore_mcp: '%pimcore_studio_backend.mcp_firewall_settings%'
- so a partial block there does not merge: it replaces the entire firewall
definition and takes the pattern, provider and custom authenticators with it.
Following the old snippet would have disabled MCP authentication rather than
retuned it.

The real extension point is the parameter itself, which the bundle sets only
when it is not already defined. Verified against a running application: an
application-supplied parameter survives with its own max_attempts and interval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Restore PatAuthenticator's 401 and harden the throttling response

Independent review found that this branch had silently dropped a security
behaviour. PatAuthenticator is the last authenticator on the MCP firewall - the
other two return null to fall through to it - and it previously answered every
failure with a 401 JsonResponse. Routing its failure handler through the
throttling trait made it return null for any non-throttling failure, so an
invalid PAT no longer produced a response at all and the request continued
unauthenticated, leaving the 401 to whatever role check the consuming bundle
happens to declare. Restored, with a regression test that fails without it.

Also from review:

  - Retry-After could be 0. The threshold is ceil((reset - now) / 60) and is 0
    at a window boundary, which advertised an immediate retry. Floored to one
    minute.
  - The dynamic-token throttle identifier was a truncation of the very SHA-256
    value McpAccessTokenService stores as the token's server-side verifier, and
    it is written to the request's LAST_USERNAME attribute. Domain-separated so
    the digest is unrelated to the stored verifier.
  - Use the bundle's HttpResponseCodes enum rather than Response::HTTP_*
    constants, which appear once in src/ against ~1189 uses of the enum.
  - Corrected the claim that retrying while throttled extends the lockout. The
    fixed-window limiter declines an over-limit request without recording it;
    what actually happens is that both tiers are consumed on every attempt, so a
    locally-blocked client keeps draining the shared global per-IP budget.
  - Corrected the authenticator-chain description, which claimed all three
    return null on failure.
  - Test fixes: a tautological assertTrue(true), and (bool) casts that hid the
    difference between supports() returning null and false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zK7dwM71xcjbp675aiAEQ

* Narrow MCP throttling to guessed PATs and split the MCP rate limiter

The throttling added earlier keyed dynamic pmcp_ tokens per token and leaned on
Symfony's derived per-IP tier for anything a per-token bucket could not catch.
That aimed at the wrong threat and paid for it with collateral: a pmcp_ token is
32 random bytes, so guessing one is not a reachable attack and a counter adds
nothing, while the per-IP tier is peeked by every client on an address - so
guesses could push an unrelated valid credential into a 429.

Only static PATs are guessable, and PatAuthenticator already handles them well:
an unrecognised token resolves to a shared placeholder identifier, which is a
per-IP failed-guess counter in all but name. That is kept and becomes the whole
mechanism.

- McpLoginRateLimiter replaces DefaultLoginRateLimiter via login_throttling.limiter.
  It has one tier and hands out a bucket only for the placeholder identifier, so a
  credential that resolves to a user is exempt from both the block and the count.
  A successful authentication cannot be rejected because no bucket exists to
  reject it from - a property of the keying, not a threshold.
- McpAccessTokenAuthenticator and SessionBridgeAuthenticator go back to their
  previous shape, and McpThrottlingResponseTrait folds into its only remaining
  user. The 429 + Retry-After mapping stays on PatAuthenticator.
- MCP request volume moves off studio_api_general onto studio_mcp_general
  (3000/min per IP). The Studio number is sized for a browser UI, while a single
  agent server can serve every chat in an installation from one address, so the
  two budgets should not share a bucket.
- ApiExceptionSubscriber now covers /pimcore-mcp/ as well, or the 429 that
  RateLimitSubscriber newly raises there would miss the Studio JSON envelope.

Co-Authored-By: Claude <noreply@anthropic.com>

* Catch the expected throttling in the guess loop

testAValidCredentialIsNotBlockedByGuessesFromTheSameIp drives twenty guesses to
show they cannot reach a valid credential, but from the sixth onwards the
attacker is blocked and checkPassport() throws - which failed the test rather
than the assertion it was setting up.

Co-Authored-By: Claude <noreply@anthropic.com>

* Correct the limiter wiring sentence and list the new MCP limiters

The MCP page had the substitution backwards: login_throttling.limiter is how
McpLoginRateLimiter is supplied, not how Symfony's default arrives - the default
is what Symfony builds when that option is absent.

04_Rate_Limiting.md was missed entirely. It is the reference table for every
limiter the bundle ships, and studio_mcp_general and studio_mcp_login were not
in it. Both are listed now, along with two distinctions that are easy to get
wrong: MCP paths use studio_mcp_general instead of studio_api_general rather
than on top of it, and rate_limiting.enabled: false does not reach
studio_mcp_login, which lives on the firewall rather than in the subscriber.

Co-Authored-By: Claude <noreply@anthropic.com>

* Scope the exception subscriber to the 429 and correct the throttling claim

Review findings on the previous commits.

ApiExceptionSubscriber claimed every HttpException raised on /pimcore-mcp/, not
just the one this bundle raises there. MCP is JSON-RPC and owns its error shapes,
so a 404 from the MCP server was being rewritten into the Studio envelope. Only
RateLimitException is claimed now, and ApiExceptionSubscriberTest covers all four
combinations - the class had no test at all before.

The "a successful authentication is never throttled" claim was too strong, and a
reproduction against the real AuthenticatorManager shows why: the manager keeps
running authenticators after one has already succeeded, so a request carrying a
valid session cookie *and* an unrecognised PAT is still judged on the PAT and can
be answered with the 429. Such a request already failed before throttling existed
- PatAuthenticator owns the terminal response and answered 401 - so the status
code is what changed, not whether it works. The guarantee is stated per credential
now, in both the interface docblock and the docs.

The unknown-credential sentinel gains a leading NUL. PatAuthenticator takes the
identifier straight from the configured token map, so a plain "__invalid__" would
collide with a Pimcore user of that name and pull their valid PAT into the guess
bucket; no username can contain a NUL.

Also: import order in PatAuthenticatorTest (ordered_imports covers tests/), and a
missing overflow test for the MCP request limiter.

Co-Authored-By: Claude <noreply@anthropic.com>

* Trim the MCP throttling docs to what a reader needs

The section had grown to explain why the design is the way it is: the tier
mechanics of the limiter it replaces, when a fixed window resets, why retrying
does not extend a lockout, which login_throttling keys go unread. That is
implementation rationale, and the code comments already carry it.

What is left is what a reader has to act on - the rate and the response, which
credentials are counted and which are not, the one-credential-per-request rule,
trusted proxies, and the two config blocks to copy.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#1972)

* Add the shared MCP tool boundary: error handler and schema normalizers

This bundle already owns the MCP infrastructure every tool-hosting bundle
depends on: the firewall pattern, the access-token entity, repository, service
and GC task, the three authenticators, and the PSR-7 bridge in config/mcp.yaml.
It owns no tools, which is exactly why the boundary belongs here rather than in
any one bundle that has them.

Three classes have been developed and hardened in pimcore-agent-bundle and are
moved here so they stop being copied. `McpToolErrorHandler` and
`ObjectParameterNormalizer` already exist, separately and by now divergently, in
pimcore-agent-bundle, copilot-bundle and data-hub-simple-rest (126, 126 and 268
lines). `ToolInputSchemaNormalizer` exists only in the agent bundle, so the
other two never got its fix systemically and data-hub-simple-rest widened the
affected parameters by hand instead.

None of the three needs mcp/sdk, so this adds no dependency: the handler needs
PSR-3, and both normalizers are array in, array out.

What they do:

- `McpToolErrorHandler` is the terminal `catch (Throwable)` of an MCP tool. Tool
  results leave the Pimcore boundary — external clients forward them to
  whichever third-party model they are wired to — so it forwards no exception
  message at all, logs the exception in full, and answers with a generic
  sentence plus a correlation id. Note that Symfony does not do this for you: a
  tool catches its own exception and returns the text as application data inside
  an HTTP 200 JSON-RPC result, so kernel.debug and the production error page
  never see it.

- `InvalidMcpToolArgumentException` is the one type it forwards. Throwing it is
  an explicit statement that the message was composed for the caller out of the
  caller's own input.

- `ToolInputSchemaNormalizer` and `ObjectParameterNormalizer` are two halves of
  one mechanism: a #[Schema] attribute pins a parameter to a single type,
  contradicting the `default: null` the SDK still emits, so the schema has to be
  widened to accept null and `{}`; widening to admit `array` also admits a
  populated list, which the second half rejects.

There is deliberately no allowlist of "client-safe" exception types. Safety is a
property of the construction, not of the class: in this bundle alone
`ValidationFailedException` is given a caller-facing literal at five call sites
and an inner getMessage() at CloneService and WidgetValidationService, so any
class-level marking of it is wrong half the time. Where a foreign message really
is worth forwarding, the tool type-catches it and says why at the catch.

Additive only; no existing behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sra46591DFmHjHQag4fCoT

* Address review: unresolvable @see refs, entropy failure at the boundary

Three points from the Copilot review.

`@see McpToolErrorHandler` and `@see ObjectParameterNormalizer` in the exception
docblock resolved against `Mcp\Exception`, where neither class exists, so
generated docs and IDE navigation pointed nowhere. Imported.

`random_bytes()` can throw on entropy exhaustion, and it was called before the
log write in the terminal error boundary. That is the worst possible place for a
throw: the tool's original exception would be neither logged nor converted, and a
`RandomException` would escape in its place. The id now comes from
`correlationId()`, which falls back to a hash of `hrtime()` — monotonic,
non-blocking, cannot throw. `random_bytes()` stays the primary source only
because it is the one that does not raise Sonar's weak-randomness hotspot; a
correlation id is not a secret and only has to be greppable.

The numeric-key case is real and is now documented rather than silently accepted.
`{"0":"a","1":"b"}` is rejected even though the schema says `object`, but that is
not a choice this class can make differently: `Mcp\JsonRpc\MessageFactory` decodes
the payload with `json_decode($input, true)` before any tool is reached, PHP casts
numeric string keys to integers, and the two shapes decode to *identical* values —
`===` returns true. No downstream inspection can separate them. The class docblock
now states the contract, names the escape hatch (any non-integer key, or integer
keys not sequential from zero), and says which parameters should not use this
guard at all.

Tests cover all three: the decode identity is asserted alongside the rejection, so
the test fails if that upstream behaviour ever changes and the limitation could be
lifted; plus the mixed-key acceptance and correlation-id well-formedness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sra46591DFmHjHQag4fCoT

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kingjia90 and others added 12 commits August 20, 2026 11:32
* Add notification subscription framework

Introduces a generic, extensible notification framework: bundles contribute
notification *types* and delivery *channels* as tagged services, and each user
chooses per type whether they are notified and through which channels.

The immediate win needs no contributing bundle at all. Every notification
Pimcore writes today is untyped, so it falls into a built-in catch-all type
whose pop-up preference is honoured when the notification is published over
Mercure. That turns today's unconditional toasting into a choice for workflow
transitions, user-to-user messages and anything a bundle writes directly,
without touching a single producer.

Design notes worth keeping in mind when extending this:

- A type declares only *whether* it may leave the application, never through
  which channel. Supported channels are derived from that capability, so a
  bundle contributing a Teams channel lights up for existing types without
  those bundles being edited.
- The pop-up is modelled as a channel from the user's point of view but is not
  a transport: it is a preference read at publish time. Storing it in the same
  JSON set is what keeps the schema stable when channels are added.
- No channel implementation ships here. The only type present is the catch-all,
  which deliberately allows no external delivery — a bucket of unclassified
  notifications is not something to email. Whichever bundle first contributes
  an externally-deliverable type contributes the channel alongside it.
- Type ids are capped at 20 characters because notifications.type is
  VARCHAR(20) and MySQL truncates silently outside strict mode. The registry
  rejects violations at boot rather than letting a truncated id match nothing.
- The catch-all reports a different label when it is the only registered type:
  there is nothing for it to be "everything else" to.

NotificationMinimal gains popup and payload. Both are additive and popup
defaults to true, so a client that has not adopted them behaves as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Always register the notification catch-all type

The catch-all was expected to arrive through the service tag like any other
descriptor. When the tag was not applied the registry held nothing, so a bare
installation reported no subscribable types and the preferences screen came up
empty — found by calling the endpoint against a running app.

Registering it directly is also the better design regardless of the tag: every
notification ever written falls into this type, and on an installation with no
contributing bundle it is the only one there is. Its presence should not be
something wiring can break.

Also fixes the channel translation key prefix, which did not match the keys
shipped in studio-ui.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Mark the framework's internal contracts @internal

The module marks its internal service, repository and hydrator interfaces
@internal; the new internal contracts were missing it. Adds it to the internal
registry/subscription interfaces and the internal EffectiveSubscription value
object, so the public surface stays limited to what is genuinely meant for
external use — the descriptor and channel interfaces, the dispatcher, the
DispatchableNotification producers build, the subscription-collection event, and
the API schemas — all of which deliberately keep no @internal.

Docblock only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Tag notification descriptors and channels via a compiler pass

The framework's extensibility relies on bundles contributing type descriptors
and delivery channels as tagged services, collected by the registries' tagged
iterators. That tagging was expressed as #[AutoconfigureTag] on the interfaces —
which, it turns out, does not tag implementers in Pimcore's container (the
existing tagged collectors here, e.g. GDPR providers, are all tagged explicitly
in YAML). The result was that no contributed descriptor or channel was ever
collected: the type registry only ever saw the built-in catch-all, which it adds
directly.

Surfaced while wiring collab-bundle's notification types: they registered
cleanly but never appeared. A compiler pass tags every implementer of the
descriptor and channel interfaces. It runs after all bundle extensions load, so
a type or channel from any bundle is picked up without that bundle knowing the
tag name — which is what makes the framework actually extensible. Idempotent, so
a bundle that tags explicitly is not tagged twice, and abstract definitions are
skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add an email delivery channel for notifications

The framework shipped the channel seam but no transport. This adds EmailChannel,
the first ChannelInterface implementation, so externally-deliverable types (the
Collab types today) can reach people by email as well as the bell and pop-up.

- EmailChannel resolves the recipient, language and an absolute deep link inside
  the producing request, then hands a fully-resolved SendNotificationEmailMessage
  to the pimcore_core transport. The blocking send happens in the worker, so a
  slow mail server never delays the comment or assignment that triggered it.
- The email mirrors the bell entry — the notification's own title and message plus
  a link, nothing from the payload — except one navigation hint: a producer may
  supply an app-relative deepLink (host-relative only, so a payload can never make
  the button off-site) to point at a better destination than the linked element,
  e.g. a Collab task or discussion in its Overview.
- The body is a Twig template rendered in the recipient's language. It is
  overridable: point notifications.email.template at your own template, or drop a
  file at templates/bundles/PimcoreStudioBackendBundle/notification/email.html.twig.
- Delivery rides the existing pimcore_core messenger transport (routing registered
  in the bundle extension), so the standard messenger:consume worker covers it.

Registering EmailChannel makes the Email column appear in the preferences screen
with no frontend change, respecting each type's allowsExternalDelivery and default
channels. Unit-tested for enqueue-not-inline, message content, deep-link
resolution and the host-relative guard; verified end-to-end into the mail catcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Localize the notification email chrome

Add the email channel's greeting, CTA and footer for de/es/fr/it/no/sv, sitting
inline with the other keys, and drop the section comment across every catalog
(incl. en) so the keys read like the rest of the file. The title and message
still come from the notification; en is the fallback for any missing locale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Register the transport channels only when a type can use them

In a core-only install the sole notification type is the built-in "info"
catch-all, which never allows external delivery — so the email channel would be
dead weight: an extra column on the preferences screen and an instantiated mailer
no notification could ever reach. The dispatch compiler pass now evaluates the
registered descriptors and, when none allow external delivery, drops the tagged
transport channels entirely (the in-app "popup" substrate is always available and
is not a tagged channel). Installing a bundle that contributes an
externally-deliverable type — Collab's mention/task/discussion types — brings the
channels back automatically, so nothing changes for a real Studio install.

A descriptor wired with service references or that fails to construct is assumed
external-capable, so a channel a bundle actually wants is never stripped on a
false negative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Use #[AutowireIterator] instead of #[TaggedIterator]

Symfony 8 removes the #[TaggedIterator] attribute in favour of #[AutowireIterator],
which has an identical constructor. A drop-in rename in the two notification
registries; behaviour is unchanged on Symfony 7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Simplify the notification dispatch pass

Extract the definition-skip check and the channel apply/remove loop out of
process(), bringing its cognitive complexity back under the threshold. No
behaviour change — the gating and tagging are identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Define the email routing in config/prepend, matching the bundle convention

Every other Messenger message in the bundle declares its transport in a YAML file
(execution_engine.yaml, config/prepend/*.yaml); only the notification email routing
was inline PHP in the Extension. Move it to config/prepend/notification.yaml,
loaded like the other prepend configs. Transport is unchanged (pimcore_core — a
fire-and-forget delivery, not a job, so not pimcore_generic_execution_engine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make the notification email a complete HTML document

The template was a bare <div>. Wrap it in <!DOCTYPE html> + <html lang> +
<head><meta charset> + <body>, matching Pimcore's own workflow notification email
(Pimcore\Mail does not wrap fragments). Content is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Document how to extend notifications

Add doc/03_Extending page: contributing a notification type descriptor and a
delivery channel, with the dispatch flow, the 20-char type-id cap, the config
envelope and the frontend-renderer pointer. Linked from the chapter index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add a <title> to the notification email

The <head> needs a title; use the notification's title. Fixes the SonarCloud
"Add a <title> tag to this page" reliability bug on the email template.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Validate notification type ids at container build, and stop discarding the runtime failure

The docs, the PR description and the exception text all promised that an over-long or
duplicated type id would fail at container build. It did not: both checks lived in
NotificationTypeRegistry's constructor, so a bad id compiled, deployed and passed CI, and
only surfaced as a 500 the first time anyone opened the preferences screen.

The failure was also lopsided. NotificationSavedSubscriber wraps the same resolution in
catch (Exception) and falls back to showing the toast, so the bell kept working while the
preferences screen was dead — and nothing was logged.

Move both checks into NotificationDispatchPass, which already materialises descriptors to
answer allowsExternalDelivery(). The check is best effort by construction: a descriptor
wired with service arguments cannot be read at compile time, so the registry keeps both
checks and stays authoritative. The docs now say that rather than overclaiming, and note
the consequence contributors need to know — a descriptor's constructor runs during
container compilation and must be side-effect free.

The swallowed exception in the subscriber is now logged, so a misconfigured descriptor is
no longer visible only as one screen failing in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop unavailable notification channels on save instead of rejecting the whole request

updateSubscriptions() validated every requested channel against the available set and threw,
which meant an administrator disabling a channel while the preferences screen was open cost
the user every other row in their bulk save — for something they could not influence.

It was also inconsistent with itself: a channel the type structurally cannot use was already
dropped silently a few lines further down, and resolveChannels' own docblock argued for
dropping while the loop above it rejected. The two mechanisms fought each other.

Both cases now drop. The endpoint returns the stored state so a dropped channel is visible
to the client rather than silent, and it is logged for anyone debugging one.

An unknown type id is still rejected, because that is not a race an administrator could have
caused and returning state cannot repair it — but as InvalidArgumentException (400) rather
than the registry's NotFoundException (404 ELEMENT_NOT_FOUND). It is a bad field in a request
body, not a missing resource, and 404 was undocumented in the endpoint's responses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Ship the notification labels this bundle emits, in all seven locales

The backend decides these key strings — GeneralNotificationDescriptor hardcodes the four
type keys, and SubscriptionService composes the channel keys from CHANNEL_TRANSLATION_PREFIX
and the channel id — but the values lived in studio-ui-bundle, in English only.

That put ownership in the wrong place: renaming a channel id here would break a label there
with no test failing in either repository, and a backend release would render raw keys until
the frontend caught up.

Verified that backend-owned keys do reach the UI before moving them: 22 keys in this file
are already consumed by the frontend and defined nowhere else (the studio_ee_job_* family),
served through the studio domain catalogue by getAllTranslationsByLocale.

studio-ui-bundle#3913 must drop these six keys when it lands; the ~20 notifications.settings.*
keys stay there, as the frontend composes those itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Narrow the materialised descriptor instead of asserting its type

SonarCloud php:S1488 on the temporary $descriptor introduced by the previous commit. The
variable only existed to carry a /** @var */ annotation, because newInstanceArgs() returns
`object` while the method returns ?NotificationTypeDescriptorInterface.

Replace the annotation with a real instanceof narrowing. The check is a formality — the
caller only reaches materialise() for a class that already passed is_a() — but it narrows
the type honestly rather than asserting something PHPStan cannot verify, and it removes the
immediate-return Sonar flagged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Trim the comments added by the previous commits

Several of them restated the commit-message rationale in the source, or narrated what the
code used to do. Kept the why, dropped the archaeology.

* Harden the notification dispatcher and the channel gate (#2001)

* Gate transport channels by clearing the tag, not removing the definition

The gate removed the channel's service definition when no registered type allows external
delivery. Aliases and references are not rewritten by removeDefinition(), so the first bundle
to alias ChannelInterface to its own channel — exactly what the extending doc shows — got a
ServiceNotFoundException at compile time as soon as the gate closed.

Untagging achieves the same thing without touching the graph: the registry collects by tag,
and an untagged private service nothing references is dropped by Symfony's own unused-
definition pass, so no dead mailer is instantiated either way.

The new test aliases the interface to a gated channel and compiles the container; it fails
with removeDefinition() and passes with clearTag().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Isolate per-recipient failures in the dispatcher, and make its decisions testable

Two problems with one cause: NotificationDispatcher::write() called Notification::save(),
which goes through a Dao, so the dispatcher could not be unit tested at all — and the write
was the only step in the fan-out that was not isolated.

The untestability was not theoretical. testBrokenChannelDoesNotPreventOtherChannelsFrom-
Delivering never called dispatch(); it built a TestChannel with throwOnSend and then asserted
on ChannelRegistry. TestChannel::$sent was written by the fixture and asserted nowhere in the
suite. The resilience guarantee ChannelInterface::send() documents in capitals was unverified,
as were the permission skip, the unsubscribed skip and the unknown-recipient skip.

Extract NotificationWriterInterface so the dispatcher holds only routing decisions, then wrap
the per-recipient body so a failed write is logged and the fan-out continues. Previously a
failure part-way through delivered to the recipients before it, silently skipped everyone
after it, and surfaced as an exception the producer could do nothing with — while deliver()
immediately below already logged and continued. The interface promised the latter behaviour.

Eight dispatcher tests now call dispatch() and assert on what the writer and the channels
actually received. Both fixes are mutation-checked: removing the guard fails the fan-out test,
and the pre-existing behaviour fails the isolation test.

The EffectiveSubscription and DispatchableNotification cases move to their own files, so
NotificationDispatcherTest is about the dispatcher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Trim comments

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address review findings on the notification framework

NotificationDispatchPass classified every definition in the container with
class_exists() plus a fresh ReflectionClass. That is ~6k classes on a real
install, and it read the definition's class name raw — so a descriptor or
channel registered as class: '%some.parameter%' was silently never tagged, and
a class with a missing parent would have raised an uncatchable fatal mid-build.
ContainerBuilder::getReflectionClass() resolves the parameter, contains the
fatal, and reuses the reflection the rest of the compilation already did.

Unsubscribing no longer wipes the stored channels. The switches say nothing
while a type is muted, and the resolver ignores them for an unsubscribed type
anyway, so overwriting the set only meant that turning a type off and on again
left the user subscribed to something that delivered nowhere — not even the
pop-up, because a stored empty set reads as a deliberate "none". Storing null
where nothing was ever chosen keeps the descriptor defaults reachable too.

The producer deep link accepted "//host", which is protocol-relative once the
host prefix is empty — and resolveHostUrl() legitimately returns an empty
string in a worker with no configured domain, the one case the host-relative
guarantee was written for.

Also: ChannelInterface still said the bundle ships no channel implementation,
which EmailChannel has since contradicted; Installer's new method landed
between an @throws docblock and createMcpAccessTokenTable(), leaving one method
with two docblocks and the other with none; the extending doc imported
UserInterface from the wrong namespace; and UpdateSubscriptionItem accepted
non-string channel ids that reached a string-typed closure as a 500.

Both behaviour changes are mutation-checked: the three new tests fail against
the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Put the catch-all's labels under the same prefix as the rest

The four keys the general descriptor emits were the only notification keys under
a singular notification.* prefix; everything else in the domain — the channel and
email chrome added here, and the notifications.* family the frontend has shipped
for as long as the bell has existed — is plural.

Renamed now because these are a one-way door: once released they are in POEditor
and in whatever a customer has overridden, and the descriptor's key is what the
API hands the frontend to render.

notification.type.general.* -> notifications.type.general.*, in the descriptor
and all seven catalogues. studio-ui-bundle#3913 still has to drop its copies of
these keys; it reads the key from the API response, so nothing there changes
beyond the removal already planned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Document that a new notification group needs a heading label

getGroup() reads as self-contained, but the preferences screen composes the
heading as notifications.settings.group.<group> rather than taking it from the
API the way the row label and description are taken. studio-ui-bundle ships only
the "general" key, so a contributed group renders its raw key as the heading.

Headings are hidden while there is one group, so the first bundle to contribute a
type is exactly the one that surfaces this — including the acme_crm example on
this page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix what running the framework against a real install turned up

Four things, found by wiring #1959 into the demo app with Collab contributing four
externally-deliverable types and driving the preferences screen through Playwright.

resolveChannels() no longer keeps the stored channels when a type is switched off. My
earlier change did, on the reasoning that re-subscribing should restore them — but the
preferences screen clears its own channel set on mute ("Mirrors the server, which clears
channels when a type is switched off"), so the client sends an empty set on re-enable and
the stored one is overwritten anyway. The change bought nothing and left the two repos
describing opposite behaviour. What is worth keeping is narrower and now applies to both
branches: a channel id this installation does not offer was never on screen, so neither a
save nor a mute is entitled to clear it.

The update endpoint documented 400 for its two rejections. Both are
InvalidArgumentException, which this bundle maps to 422, so the generated client typed the
error wrong. Documented as 422; no behaviour change.

The email greeting is "Hi %name%," fed from getFullName(), and a Pimcore user need not have
a first or last name — a seeded user produced "Hi ,". Falls back to the username.

An email dispatched with no request and no pimcore.general.domain gets a host-relative
link, which in a mail client is a dead button. Nothing better can be emitted, but the
previous comment called it "a host-relative link rather than a broken absolute one" as
though that were fine. It is logged now.

Verified against the running app: 22 Playwright specs (11 API + 3 UI, plus setup), the
email observed in the mail catcher, and the channel gate confirmed to close in a core-only
install and reopen when Collab's descriptors return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Say when a channel cannot reach the account behind the switch

The email switch stores fine and then delivers nothing when the user has no email
address on their account, which is indistinguishable from a broken channel — it
cost a real debugging session to work out that was all it was.

ChannelInterface gains unavailableReasonFor(): a translation key when the channel
cannot reach that user, null when it can. It is the channel's own question to
answer, so a chat channel can say "no linked account" without the framework
knowing what an account is. EmailChannel answers it for a missing address, and the
skip in send() is logged rather than silent.

The reason travels on AvailableChannel so the preferences screen can explain the
column instead of hiding it: the preference is real and starts working the moment
an address exists, so hiding the switch would be the wrong fix.

Adding the method now costs nothing — the framework is unreleased and EmailChannel
is its only implementer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Gate transport channels at runtime instead of compile time

The compiler pass materialised descriptors during container compilation to
decide whether any type allows external delivery, and untagged the channels
when none did — plus duplicated the type-id validation the registry already
performs authoritatively. That was ~120 lines of the trickiest code in the
framework (constructors running mid-compilation, best-effort semantics with
an assumed-external fallback) for a decision a 5-line runtime check makes
with full accuracy.

The pass now only tags implementers. The registry keeps validation (its
tests already covered duplicate, overlong and at-limit ids independently);
SubscriptionService narrows the offered channel columns via the new
NotificationTypeRegistry::hasExternallyDeliverableType(). Delivery never
depended on the compile-time gate: with no externally-deliverable type the
resolver already narrows every subscription to the pop-up.

Trade-off: a duplicate or overlong type id now fails on first use of the
registry rather than at container build. Also drops the never-called
ChannelRegistry::getEnabledChannels().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Trim comments to the load-bearing why

Interface and class docblocks carried multi-paragraph essays; several said
the same thing in three places (the null-vs-empty channels rule lived in the
entity, the migration and the installer). Comments now state the one
non-obvious fact and point elsewhere for the rest. No code changes beyond
removed comment lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Docs: validation happens in the registry, not at container build

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Docs: neutral example naming instead of ACME

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Docs: task-first restructure of the notifications page

Lead with what to do (send / add a type / add a channel) instead of how the
dispatcher works internally. The 8-row method table is gone — the example
plus two sentences carry it. Mercure/pop-up internals dropped except where
they change what an extender must do (the payload privacy warning).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Docs: explain why the dispatcher exists next to NotificationService

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Docs: say plainly what a type and a channel are (row and column)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Docs: PIM-native example (product approved) instead of a CRM deal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Docs: asset-upload example, picked over the workflow-flavoured one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Types are data: NotificationType value object replaces the descriptor hierarchy

A notification type had no behavior — the descriptor interface was eight
getters, and the abstract base class existed only to supply default values
and to insure the interface against evolution. Both jobs belong to a final
value object with constructor defaults: named arguments read like the
configuration they are, the shape is sealed, and a later addition is a new
constructor default instead of a BC break.

Bundles now register a NotificationTypeProviderInterface returning their
NotificationType instances (one provider per bundle, typically). The general
catch-all is built by the registry itself from GeneralNotificationType and
may not be claimed by a provider; its solo labels move to constants, which
also removes the instanceof special-casing in SubscriptionService.

Channels deliberately stay an interface: send() and unavailableReasonFor()
are real behavior with real dependencies.

Registry surface renamed to match (getTypes/getType/hasType/
hasOnlyGeneralType); tag renamed to
pimcore.studio_backend.notification_type_provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reuse the canonical element→type mapping in EmailChannel

EmailChannel::studioElementType() had its own Asset/Document/DataObject →
ElementTypes match — a second copy of ElementProviderTrait::getElementType().
Delegate to the trait instead, so the mapping lives in one place and the email
deep link can't silently diverge if it ever changes. The trait throws on an
unsupported type where the deep link wants "no segment", so the one behavioural
difference is preserved with a narrow catch. Drops four now-unused imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Resolve the email host via ToolResolver::getHostUrl()

EmailChannel::resolveHostUrl() hand-rolled request-host-else-domain from the
RequestStack + getHostname()/getRequestScheme(). That reimplements — less
completely (it missed the localhost and non-standard-port handling) — the
Tool::getHostUrl() that core's own workflow-notification mail uses.

getHostUrl() is already exposed on ToolResolverInterface (via the contract it
extends), so delegate to it: same helper, testable, and it internally resolves
the current request. The RequestStack dependency is now unused and dropped;
the studio-specific "cannot make links absolute" warning stays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Publish notifications on the recipient's Mercure topic, not the shared one

NotificationSavedSubscriber published to Topics::STUDIO — the topic every
Studio client subscribes to (StudioTopicProvider) — so a notification's title,
message, payload and the recipient's unread count rode the wire to all
connected users, with only the frontend's client-side recipient check keeping
them out of view. That check cannot prevent reading the data off the socket.

Publish to UserTopicService::getUserTopic($recipientId) instead. The recipient
already subscribes to their own topic (UserTopicProvider grants only the
current user's), so delivery is unchanged while no other client receives it.
Guards a null recipient (nothing to deliver to a user topic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review nits: url_path, channel dedup, @internal, test comment

- EmailChannel: resolve the Studio base path from pimcore_studio_ui.url_path
  instead of hard-coding /pimcore-studio, so a customised url_path yields
  correct email links. Wired in the extension with a fallback default since
  studio-ui is not a hard dependency.
- SubscriptionResolver: array_unique the effective channels so a type
  declaring duplicate default channels can't make the dispatcher send the
  same email twice.
- NotificationType: drop @internal — it is the object contributing bundles
  construct via NotificationTypeProviderInterface, i.e. a public extension point.
- SubscriptionServiceTest: the rejected-unknown-type comment said 400; the
  mapped status is 422.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Tighten the review-fix comments

Comment-only: trim the Mercure-topic, EmailChannel $studioPath, resolver
dedup and extension comments to the load-bearing line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Martin Eiber <martin.eiber@pimcore.com>
A replace patch with data: null ("clear the field") threw a TypeError
because handlePatch declared a non-nullable array return while
returning the payload data verbatim. The caller already supports null:
PatchService passes the adapter result straight into setValue(), where
null is the normal way to clear a field.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings August 31, 2026 12:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes multiselect PATCH operations so replace can clear a field with null.

Changes:

  • Widens the private patch handler return type to ?array (MultiSelectAdapter.php:62).
  • Correctly addresses the root cause at the adapter boundary; its sole caller already supports nullable results (MultiSelectAdapter.php:44,54).
  • No backward-compatibility or documentation impact, but a focused regression test is missing.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/DataObject/Data/Adapter/MultiSelectAdapter.php
@xIrusux
xIrusux requested a review from lukmzig August 31, 2026 12:32
@xIrusux xIrusux self-assigned this Aug 31, 2026
@xIrusux xIrusux added this to the 2026.3.0 milestone Aug 31, 2026
Co-Authored-By: Claude <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@xIrusux xIrusux modified the milestones: 2026.3.0, 2026.2.8 Aug 31, 2026
@xIrusux
xIrusux changed the base branch from 2026.x to 2026.2 August 31, 2026 12:46
@xIrusux

xIrusux commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #2019 — same change rebased onto 2026.2 so it lands on the lower release line and forward-merges up.

@xIrusux xIrusux closed this Aug 31, 2026
@xIrusux
xIrusux deleted the fix-multiselect-patch-replace-null branch August 31, 2026 12:47
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 31, 2026
@robertSt7 robertSt7 removed this from the 2026.2.8 milestone Sep 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.