Skip to content

fix(events): resolve the schema slug listeners compare against (gated, default off) - #82

Merged
rubenvdlinde merged 2 commits into
developmentfrom
fix/listener-schema-slug-resolution
Aug 2, 2026
Merged

fix(events): resolve the schema slug listeners compare against (gated, default off)#82
rubenvdlinde merged 2 commits into
developmentfrom
fix/listener-schema-slug-resolution

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

⚠️ Gated, default OFF — read before flipping the flag

This fixes a comparison that has never once been true. Correcting it wakes two listeners that have never executed, one of which is a fail-closed validation guard. The fix therefore ships behind openbuild.listener_slug_contract, default off, mirroring how openregister#2248 shipped the sibling ObjectTransitionedEvent defect.

The bug

ProductionVersionGuardListener and AutomationCleanupListener each carried a private extractSchemaSlug() that:

  1. probed for ObjectEntity::getSchemaSlug()a method that does not exist, then
  2. fell back to @self.schema, which is the schema's numeric id.

Both then compared that id against a slug literal:

$schema = $this->extractSchemaSlug(entity: $entity);
if ($schema !== ApplicationVersionService::APPLICATION_SCHEMA) {   // "28" !== "application"
    return;                                                        // ← always taken
}

ObjectEntity::$schema is declared ?string but SaveObject writes it as (string) $schemaId, so it holds "28", never "application". Every call returned at the guard. No exception, no log line — the listeners simply did nothing, silently, for their entire life.

The fix

One shared ObjectSchemaSlugResolver replaces both private helpers, resolving the id via SchemaMapper::find() (already request-cached; memoised here including misses so a hot write path does not become an N+1 — docudesk measured 1,471 find() calls per object save from exactly this shape).

It matches the register as well as the schema. A schema slug is not unique instance-wide: this instance carries two distinct schemas with the slug automation (ids 71 and 5103). Matching on schema slug alone would fire OpenBuild's handlers for another app's objects. This mirrors the register+schema pair pattern already shipped in petstore and planix.

⚠️ Blast radius — why it is gated

listener what waking it does
ProductionVersionGuardListener FAIL-CLOSED guard, currently failing OPEN. Mismatched production-version back-references are never blocked today. Enabling it starts rejecting writes that currently succeed ($event->stopPropagation() + setErrors()).
AutomationCleanupListener Starts deleting compiled artifacts when an automation is deleted. Correct behaviour — every deleted automation to date has orphaned its artifacts — but it is a destructive path that has never run.

Neither has ever executed, so neither has ever been exercised against real data. Enabling is a rollout decision, not a bug fix:

occ config:app:set openbuild listener_slug_contract --value=yes

Verification status — please read

  • php -l clean; phpcs clean on all four touched files (remaining warnings are pre-existing missing @spec tags — I have not invented spec paths).
  • The two-schemas-named-automation collision and the id-vs-slug shape are both verified directly against the database, not inferred.
  • I have not run a live positive control proving each handler body now executes for a real matching write. That is the control that matters, and it is exactly what this change enables but does not yet demonstrate. It should be done on a disposable instance with the flag on, per listener, before the flag is enabled anywhere real. Flagging this explicitly rather than implying more verification than was done.

Not addressed here

DocumentGenerationListener and AutomationApprovalTriggerListener carry the same id-vs-slug defect via schemaOf(), but they feed the id into automation trigger matching rather than a literal comparison, so they need a separate change. (AutomationApprovalTriggerListener already injects SchemaMapper and resolves correctly in the other direction at line 429 — the pattern is present in the file, just not applied to the trigger side.)

HybridMetadataLockListener and ApprovalOutcomeListener are not affected: the former documents this exact id problem and deliberately uses payload-shape detection instead.

ProductionVersionGuardListener and AutomationCleanupListener each carried a
private extractSchemaSlug() that probed for ObjectEntity::getSchemaSlug() — a
method that does not exist — and then fell back to `@self.schema`, which is the
schema's numeric id. Both then compared that id with `!==` against a slug
literal ('application', 'automation'), so the comparison was always true and
neither handler body has ever executed. No exception, no log line.

Replaces both helpers with one shared ObjectSchemaSlugResolver that resolves
the id to a slug via SchemaMapper::find() (request-cached, and memoised here
including misses so a hot write path does not become an N+1).

The resolver matches the REGISTER as well as the schema. A schema slug is not
unique instance-wide: this instance carries two distinct schemas with the slug
`automation` (ids 71 and 5103), so matching on the schema slug alone would fire
OpenBuild's handlers for another app's objects. Mirrors the register+schema
pair pattern already shipped in petstore and planix.

GATED, DEFAULT OFF — `openbuild.listener_slug_contract`.

Correcting the comparison is not behaviour-neutral. ProductionVersionGuardListener
is a FAIL-CLOSED validation guard: it fails OPEN today, so mismatched production
versions are never blocked, and waking it starts REJECTING writes that currently
succeed. AutomationCleanupListener starts DELETING compiled artifacts on
automation delete. Neither path has ever run, so neither has been exercised
against real data. The flag lets the fix ship and be reviewed without switching
all of that on in one deploy, mirroring openregister#2248's approach to the
sibling ObjectTransitionedEvent defect.

Not addressed here: DocumentGenerationListener and AutomationApprovalTriggerListener
have the same id-vs-slug defect via schemaOf(), but they feed the id into
automation trigger matching rather than a literal comparison, so they need a
separate change.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 744dba0

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 100/100
npm ✅ 659/659
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 89.4% (17/19 statements)


Quality workflow — 2026-08-01 21:41 UTC

Download the full PDF report from the workflow artifacts.

Both listeners gained two constructor params (the slug resolver and the
opt-in contract) but neither test was updated — 7 ArgumentCountError errors,
so CI was red on all six PHP/NC combinations.

The old tests were also not a control. They fed `'@self' => ['schema' =>
'automation']` — a SLUG — where MagicMapper writes a numeric ID, which is the
exact reason these listeners never ran in production. Passing the fixture the
production shape would not produce is how a dead listener tests green. The
fixtures now carry ids and the resolver decides, which is what the shipped
code does.

Added a default-off test per listener asserting that nothing happens at all —
not even the slug lookup. That is the merge-safety assertion: the production
version guard is fail-closed and currently fails open, so waking it starts
rejecting writes that succeed today.

739 tests, 0 failures (was 737 with 7 errors).
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 866d464

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 100/100
npm ✅ 659/659
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 89.4% (17/19 statements)


Quality workflow — 2026-08-02 07:18 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 60c8883 into development Aug 2, 2026
30 checks passed
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.

2 participants