Skip to content

fix(import): an annotation-only schema change must not be skipped - #2525

Open
rubenvdlinde wants to merge 2 commits into
developmentfrom
fix/schema-content-differs-configuration
Open

fix(import): an annotation-only schema change must not be skipped#2525
rubenvdlinde wants to merge 2 commits into
developmentfrom
fix/schema-content-differs-configuration

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

The bug

ImportHandler::schemaContentDiffers() compared properties, required and authorization and nothing else. A change that only adds or edits an x-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: queued forever.

  • exportJob declares x-openregister-lifecycle at version 0.1.0; the instance carried 1.0.0 (editing a schema once through the UI bumps it to 1.0.0).
  • Version said skip. The content check could not see that the stored schema had no lifecycle at all.
  • So TransitionEngine::transition() found no state machine and returned without doing anything. RunExportJob ran, its oc_jobs row was consumed, the object never moved off queued, and not one line was logged.
  • Adding the lifecycle to the stored schema by hand fixed it immediately: the object's available-actions went from empty to start → running, and firing it moved the object to running.

The control case is in the same app: applicationVersion declares 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 new schemaAnnotationsDiffer().

Two deliberate narrowings, both to avoid re-importing on every settings load:

  1. Only keys in Schema::ANNOTATION_VOCABULARY. An unknown key is dropped on every save, so comparing it would differ forever — openbuild really does declare x-openregister-lifecycle-exception.
  2. Only keys the incoming declares. The stored configuration also holds keys OpenRegister maintains itself (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, 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

ImportHandlerSchemaContentDiffTest gains 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

`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.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ d787ae8

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.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 6eec12d

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.

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.

1 participant