fix(import): an annotation-only schema change must not be skipped - #2525
Open
rubenvdlinde wants to merge 2 commits into
Open
fix(import): an annotation-only schema change must not be skipped#2525rubenvdlinde wants to merge 2 commits into
rubenvdlinde wants to merge 2 commits into
Conversation
`schemaContentDiffers()` compared `properties`, `required` and
`authorization` and nothing else, so a change that ONLY adds or edits an
`x-openregister-*` block was invisible to it. Combined with the version gate
("skip when incoming <= existing") that is a silent, permanent no-op: the
annotation sits declared in the app's register JSON, visible in the repo, and
never reaches the running system.
Measured on openbuild: `exportJob` declares `x-openregister-lifecycle` at
version 0.1.0 while the instance carried 1.0.0 — a schema edited once through
the UI bumps to 1.0.0. Version said skip; the content check could not see the
missing lifecycle; `TransitionEngine::transition()` found no state machine and
returned without doing anything. Every export sat at `status: queued` forever
with its background job consumed and not one line in the log. Adding the
lifecycle to the stored schema by hand fixed it immediately — the object's
`available-actions` went from empty to `start → running`.
The same trap applies to every key in the vocabulary: mcp, calculations,
notifications, widgets, relations, archival.
Two deliberate narrowings, both to avoid re-importing on every settings load:
only keys IN `Schema::ANNOTATION_VOCABULARY` are compared (an unknown key is
dropped on every save, so comparing it would differ forever — openbuild really
does declare `x-openregister-lifecycle-exception`), and only keys the INCOMING
declares are compared (the stored configuration also holds keys OpenRegister
maintains itself and annotations an operator added through the UI; treating
those as a difference would rewrite them away).
Incoming annotations are read from both the top level and `configuration`,
because `Schema::hydrate()` folds the sibling-of-properties form into the
nested one. `ANNOTATION_VOCABULARY` becomes public so the import path can
share the one list.
Tests: two new cases fail on the old behaviour and pass on the new (verified
as a control pair); three more pin the anti-churn narrowings so a future
widening cannot silently reintroduce per-load rewrites.
Contributor
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-16 10:23 UTC
Download the full PDF report from the workflow artifacts.
getConfiguration() is typed ?array, so the null-coalesce already yields an array and the guard is unreachable. PHPStan caught it through its ignore budget rather than as a new error: the always-false pattern is allowed to occur once in this file and my guard made it twice. The sibling guard on $incomingConfig stays — $data is array<string,mixed>, so $data['configuration'] really is mixed and that check is load-bearing.
Contributor
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-16 11:10 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
ImportHandler::schemaContentDiffers()comparedproperties,requiredandauthorizationand nothing else. A change that only adds or edits anx-openregister-*annotation was invisible to it — and combined with the version gate above it (skip when incoming <= existing) that is a silent, permanent no-op. The annotation sits declared in the app's register JSON, visible in the repo, and never reaches the running system.How it was found
Chasing an openbuild export that sat at
status: queuedforever.exportJobdeclaresx-openregister-lifecycleat version 0.1.0; the instance carried 1.0.0 (editing a schema once through the UI bumps it to 1.0.0).TransitionEngine::transition()found no state machine and returned without doing anything.RunExportJobran, itsoc_jobsrow was consumed, the object never moved offqueued, and not one line was logged.available-actionswent from empty tostart → running, and firing it moved the object torunning.The control case is in the same app:
applicationVersiondeclares 0.4.0, is deployed at 0.4.0, and carries its lifecycle correctly.The same trap applies to every key in the vocabulary —
mcp,calculations,notifications,widgets,relations,archival.The fix
schemaContentDiffers()now also compares the declared annotations, via a newschemaAnnotationsDiffer().Two deliberate narrowings, both to avoid re-importing on every settings load:
Schema::ANNOTATION_VOCABULARY. An unknown key is dropped on every save, so comparing it would differ forever — openbuild really does declarex-openregister-lifecycle-exception.objectNameField, …) and annotations an operator added through the UI; treating those as a difference would rewrite them away.Incoming annotations are read from both the top level and
configuration, becauseSchema::hydrate()folds the sibling-of-properties form into the nested one.ANNOTATION_VOCABULARYbecomespublicso the import path can share the one list.Tests
ImportHandlerSchemaContentDiffTestgains five cases. Two of them fail on the old behaviour and pass on the new — verified as a control pair (reverting the one-line call site reproduces exactly those two failures, 10 tests / 2 failures). The other three pin the anti-churn narrowings so a future widening cannot silently reintroduce per-load rewrites.🤖 Generated with Claude Code