Skip to content

fix(listener): the meeting-folder listener has never once run (#471) - #472

Merged
rubenvdlinde merged 1 commit into
developmentfrom
fix/listener-schema-resolution
Aug 12, 2026
Merged

fix(listener): the meeting-folder listener has never once run (#471)#472
rubenvdlinde merged 1 commit into
developmentfrom
fix/listener-schema-resolution

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes #471.

What was wrong

MeetingFolderListener::resolveSchemaSlug() could not return meeting for any object OpenRegister has ever dispatched, for two independent reasons. Repairing either one alone leaves the listener dead — which is why this change does both.

A four-arm control over a single production-shaped entity (numeric schema id on the entity, no schema key on the payload), all four values printed:

arm resolved matches meeting?
A — origin/development, method_exists() '' no
B — probe swapped to is_callable() raises BadFunctionCallException no
C — probe fixed to property_exists(), value untouched '93' no
D — this change 'meeting' yes

1. The probe. ObjectEntity declares getSchema() only as an @method docblock tag; Entity::__call() serves it. method_exists() is therefore false for it and the getter tier was skipped for every entity that actually has a schema. Arm B is why the probe was not simply swapped: is_callable() is true for any name on a __call class, so it converts a silently-false guard into a silently-true one and the call then raises. Entity::__call() routes get* to Entity::getter(), which decides on property_exists() — so that is the instrument used here, with the call additionally made exception-safe in the same edit.

2. The value. MagicMapper and SaveObject stamp the schema's numeric database id onto every entity they materialise (setSchema((string) $schema->getId())). Arm C shows the correctly-probed read returning '93' against a guard comparing to the slug meeting. The id is now resolved back to its slug through OpenRegister's SchemaMapper, memoised per request — the unfiltered-registration fallback in ObjectListenerRegistrar invokes listeners on every object write instance-wide, so an unmemoised lookup would be a database read per write.

Decidesk's own PortalCreateOpenParentGuardListener docblock already recorded reason 2 ("the REAL ObjectEntity::getSchema() returns the schema's numeric database id, not its slug"), which is why that listener carries a Tier-2 signature fallback and this one does not.

Why the unit suite was green over a listener that could not fire

The stub is faithful — tests/Stubs/Db/ObjectEntity.php honours the decidesk#399 signature parity contract and deliberately does not declare the magic accessors. The fixtures were not: every existing test fed a _schemaSlug key that OpenRegister never emits. The parity contract constrains the double; it does not constrain the payload. The new tests build the entity the way MagicMapper does and drive the real resolver rather than a mocked answer.

tests/Stubs/Db/Schema.php gains a concrete setSlug() — concrete in production at lib/Db/Schema.php:1714 — and a note recording that getSlug() stays magic on purpose.

Proven able to fail

Both halves were reverted separately with the prediction written first, and both predictions matched exactly: removing the id-to-slug resolution fails five named tests; restoring method_exists() as the only probe fails six. Reverts were made with an editor, not a script.

Behaviour change

Meeting creates now get their Files folder tree, which they never have. The call is fail-soft and bounded — one tree per meeting create, not a fan-out — and an unresolvable schema fails closed, so no tree is created for an object that might not be a meeting. Nothing that previously errored now succeeds; the change adds a side effect, it does not open a path.

Deliberately not in this change

SubmissionDeadlineListener and GovernanceRoleProjectionListener resolve their slug the same dead way and are dead for the same two reasons. Waking those changes what the write path permits — a pre-write veto and a set of RBAC scope projections respectively — rather than what it creates, so they are sequenced behind their own verification instead of being carried in on the back of this one.

Local verification: lint, phpcs (0 errors), phpmd and the full unit suite pass. Psalm and PHPStan cannot run in a host checkout here because vendor/nextcloud/ocp/OCP is a symlink into the container; both are left to CI.

Two independent defects, and repairing either one alone leaves the listener
dead. A four-arm control over one production-shaped entity, printed:

  A  origin/development (method_exists) ......... ''         no match
  B  probe swapped to is_callable() ............. THREW      no match
  C  probe fixed to property_exists() only ...... '93'       no match
  D  this change ................................ 'meeting'  match

1. The probe. ObjectEntity declares getSchema() only as an @method docblock
   tag; Entity::__call serves it, so method_exists() is false for it and the
   getter tier was skipped for every entity that actually has a schema.
   is_callable() is not the remedy: arm B shows it is true for any name and
   the call then raises BadFunctionCallException. Entity::__call routes get*
   to Entity::getter(), which decides on property_exists(), so that is the
   instrument used here, with the call additionally made exception-safe.

2. The value. MagicMapper and SaveObject stamp the schema's numeric database
   id onto every entity they materialise, so arm C reads '93' where the guard
   compares against the slug 'meeting'. The id is now resolved back to its
   slug through OpenRegister's SchemaMapper, memoised per request because the
   unfiltered-registration fallback invokes listeners on every object write.

Why the suite was green over a listener that could not fire: the stub is
faithful — tests/Stubs/Db/ObjectEntity.php honours the decidesk#399 parity
contract and does not declare the magic accessors — but every fixture fed a
_schemaSlug key that OpenRegister never emits. The parity contract constrains
the double; it does not constrain the payload. The new tests build the entity
the way MagicMapper does and assert through the real resolver.

Both halves are proven able to fail. Removing the id-to-slug resolution fails
exactly 5 tests; restoring method_exists as the only probe fails exactly 6.
Both predictions were written before the reverts and matched.

Behaviour change: meeting creates now get their Files folder tree, which they
never have. The call is fail-soft and bounded (one folder tree per meeting
create), and an unresolvable schema fails closed - no tree is created for an
object that might not be a meeting.

Not touched, same defect, filed separately: SubmissionDeadlineListener and
GovernanceRoleProjectionListener resolve their slug the same dead way. Waking
those two changes what the write path permits rather than what it creates, so
they are sequenced behind their own verification.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/decidesk @ 674ae8c

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
composer ✅ 100/100
npm ✅ 548/548
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-12 00:07 UTC

Download the full PDF report from the workflow artifacts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MeetingFolderListener never runs — resolveSchemaSlug() probes every candidate getter with method_exists(), which is false for all of them

2 participants