fix: event listeners cannot constructor-inject a published OpenRegister interface - #228
fix: event listeners cannot constructor-inject a published OpenRegister interface#228rubenvdlinde wants to merge 3 commits into
Conversation
An OpenBuild listener was killing OTHER apps' requests.
Measured 2026-08-16: an ObjectCreatedEvent raised while Hermiq persisted a chat
conversation aborted the entire chat turn with
Could not resolve OCA\OpenRegister\Contract\ObjectServiceInterface!
#3 AutomationApprovalTriggerListener.php
#5 ServiceEventListener->handle(OCA\OpenRegister\Event\ObjectCreatedEvent)
-> ChatController.php:273 "Failed to send message"
A Hermiq request, ended by an OpenBuild listener, over an OpenRegister event --
which is three apps deep and none of their authors would think to look there.
WHY THE ALIAS COULD NEVER WORK. ADR-084 has consumers type-hint the published
interface and bind it with registerServiceAlias() in their own composition root.
This app does exactly that (Application::register():110), and it is correct for
controllers and services because those are built from the APP container.
Listeners are not. Nextcloud's OC\EventDispatcher\ServiceEventListener resolves
the listener class from the SERVER container, and says so in its own source:
// TODO: fetch from the app containers, otherwise any custom services,
$this->service = $this->container->get($this->class);
The server container has never seen this app's alias, so the constructor
parameter could not be built. This is a structural limit of the ADR-084 pattern
at the listener boundary, not a typo -- all THREE listeners that injected the
interface were dead the same way (ApprovalOutcomeListener,
AutomationApprovalTriggerListener, DocumentGenerationListener), and each had
been dead since it was migrated to the interface.
THE FIX RESOLVES THE CONCRETE CLASS, AND THAT DISTINCTION IS THE WHOLE REPAIR.
An earlier draft of this commit resolved ObjectServiceInterface::class through
the injected container -- which fails identically, because the container a
listener receives IS the server container. It would only have moved the error
later. Nextcloud autowires concrete classes across apps (this app's own
composition-root comment says so), so Service\ObjectService::class resolves
where the alias cannot.
The DECLARED TYPE stays the published contract: ObjectService implements it, so
every call site and every test still sees only the ADR-084 interface. The
concrete name appears once per listener, at the container boundary -- the same
shape DocuDesk already uses in DocumentObjectServiceResolver. The other
OpenRegister dependencies in these constructors are concrete classes and were
always fine; only the interface needed this.
Tests updated to hand each listener a container stub returning the same
ObjectServiceInterface mock. The mock and the assertions are unchanged: only the
delivery route is.
NOT VERIFIED BY THE UNIT SUITE. openbuild's PHPUnit bootstrap fails in this
container with `Interface "OCA\OpenRegister\Contract\ObjectEntityInterface" not
found` from tests/stubs/openregister-stubs.php. That is PRE-EXISTING and was
confirmed as such: the identical failure occurs with the branch's own untouched
test files. It needs its own fix and is not this change.
Now verified live — correcting the caveat in the descriptionThe description says this was deployed but not demonstrated. It has since been demonstrated on the dev instance. The exact turn that crashed, re-run against the fix:
The turn creates a conversation object, so it raises the
The other caveat in the description still stands: the unit suite still cannot run in this container because of the pre-existing |
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| check-gitignore | ✅ | ||||
| check-nc-floor | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 106/106 | |||
| npm | ✅ | ✅ 626/626 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-16 20:35 UTC
Download the full PDF report from the workflow artifacts.
… was removed `PHPDoc tag @PARAM references unknown parameter: $objectService` — the one error reddening `PHP Quality (phpstan)`, a job `development` passes, so it was this branch's only real regression. The constructor swapped `ObjectServiceInterface $objectService` for `ContainerInterface $container` when the resolution moved to the lazy objectService() accessor, but the docblock kept the old tag. The two sibling listeners in this same change (ApprovalOutcomeListener, DocumentGenerationListener) were already updated — this one was missed, which is why phpstan reported exactly one error rather than three. Documentation-only: no behaviour change.
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| check-gitignore | ✅ | ||||
| check-nc-floor | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 106/106 | |||
| npm | ✅ | ✅ 626/626 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-16 23:58 UTC
Download the full PDF report from the workflow artifacts.
…resolve-objectservice-lazily
An OpenBuild listener was killing other apps' requests
Measured on the dev instance 2026-08-16. An
ObjectCreatedEventraised while Hermiq was persisting a chat conversation aborted the entire chat turn:A Hermiq request, ended by an OpenBuild listener, over an OpenRegister event. Three apps deep, and none of their authors would think to look there.
Why the alias could never have worked
ADR-084 has consumers type-hint the published interface and bind it with
registerServiceAlias()in their own composition root. This app does exactly that (Application.php:110), and it is correct — for controllers and services, which are built from the app container.Listeners are not. Nextcloud's
OC\EventDispatcher\ServiceEventListenerresolves the listener from the server container, and says so in its own source:The server container has never seen this app's alias. This is a structural limit of the ADR-084 pattern at the listener boundary, not a typo — all three listeners that injected the interface were dead the same way, each since it was migrated.
The fix resolves the CONCRETE class, and that distinction is the whole repair
An earlier draft of this change resolved
ObjectServiceInterface::classthrough the injected container. That fails identically — the container a listener receives is the server container — and would only have moved the error later. Nextcloud autowires concrete classes across apps (this app's own composition-root comment says so), soService\ObjectService::classresolves where the alias cannot.The declared type stays the published contract.
ObjectServiceimplements it, so every call site and test still sees only the ADR-084 interface; the concrete name appears once per listener at the container boundary — the same shape DocuDesk already uses inDocumentObjectServiceResolver. The other OpenRegister dependencies in these constructors are concrete classes and were always fine.Tests
Each listener now receives a container stub returning the same
ObjectServiceInterfacemock. Mocks and assertions unchanged — only the delivery route.Interface "OCA\OpenRegister\Contract\ObjectEntityInterface" not foundfromtests/stubs/openregister-stubs.php. That is pre-existing and confirmed as such — the identical failure occurs with this branch's own untouched test files. It needs its own fix and is not this change.Worth a fleet check
Any app that migrated a listener to a published OR interface has this bug, and it is silent until the event fires. A sweep of
lib/Listener/**forprivate readonly *InterfacefromOpenRegister\Contractfound these three; other repos should be checked the same way.