Skip to content

fix(lifecycle): initial-only x-openregister-lifecycle must not fail-closed every status change#2040

Merged
rubenvdlinde merged 2 commits into
developmentfrom
fix/lifecycle-empty-transitions-failclose
Jul 22, 2026
Merged

fix(lifecycle): initial-only x-openregister-lifecycle must not fail-closed every status change#2040
rubenvdlinde merged 2 commits into
developmentfrom
fix/lifecycle-empty-transitions-failclose

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Problem

LifecycleValidationListener validates a status change against the schema annotation's transitions and rejects when no transition matches (findTransitionByTarget(...) === null). But there is no guard for an empty/absent transition set.

A schema may legitimately declare x-openregister-lifecycle with only an initial block and no transitions — to have OR derive the start state while the app owns transition validation itself. procest's case schema does exactly this:

{ "field": "status", "initial": { "from": "caseType", "field": "initialStatus" } }

procest routes every advance through its own workflow-template state engine. With no declared transitions, findTransitionByTarget([]) returns null, so the listener rejected every status change (lifecycle-invalid-transition) — silently breaking case status advancement app-wide. procest's transition engine surfaces it as a 500 (No transition allows moving "status" from … to …).

Fix

Treat an empty/absent transitions set as app-managed: skip transition enforcement (early return). The initial state is still pinned by LifecycleInitialStateListener, and schemas that DO declare transitions are still fully enforced, including their guards.

Verification

Reproduced + fixed on a live NC-32 instance (procest + OpenRegister). Before: procest executeTransition → 500 No transition allows moving …. After: procest's case state-machine e2e specs pass — the advance transition succeeds and an invalid transition (missing required field) is still correctly blocked.

Companion to #2035 (both surfaced running procest's live-NC e2e on NC 32).

🤖 Generated with Claude Code

Conduction Release Bot added 2 commits July 20, 2026 13:03
…ses every internal read

RegisterMapper::getAllRegisterIdsWithSchema() and SettingsController's diagnostic
query called Doctrine's fetchAllAssociative() on Nextcloud's IResult
(OC\DB\ResultAdapter), which does not expose that method on every supported
server — it is absent on NC 32 and throws 'Call to undefined method'.

getAllRegisterIdsWithSchema() runs inside PermissionHandler's authorization
resolver (getRegisterForSchema); its catch re-throws as
AuthorizationUnresolvableException and FAIL-CLOSES the action. The net effect on
NC 32: every RBAC-checked read via ObjectService::find() — dashboard widgets, the
status-transition engine (available-transitions → current status never resolves),
single-object reads — is denied even for admin, while the list path (which uses
findMultipleOptimized, no per-row resolver) still works, masking the cause.

Iterate fetch() instead, matching the fix already applied in MarkerLookupTrait.
Verified on a live NC-32 instance: available-transitions now resolves the status
name and procest dashboard widgets return 200 instead of 403.

DbalObjectSourceProvider's fetchAllAssociative() calls are left as-is — those run
against an external raw \Doctrine\DBAL\Query\QueryBuilder whose Result does
expose the method.
…losed every status change

LifecycleValidationListener validates a status change against the schema
annotation's `transitions`, rejecting when no transition matches. But a schema
may legitimately declare `x-openregister-lifecycle` with ONLY an `initial` block
and NO `transitions` — to have OR derive the start state while the app owns
transition validation itself. procest's `case` schema does exactly this
(`{ field: status, initial: { from: caseType, field: initialStatus } }`) and
routes every advance through its own workflow-template state engine.

With no declared transitions, `findTransitionByTarget([])` returns null, so the
listener rejected EVERY status change (`lifecycle-invalid-transition`) — silently
breaking case status advancement app-wide (procest's transition engine 500s with
"No transition allows moving …"). Treat an empty/absent transition set as
"app-managed" and skip transition enforcement; the initial state is still pinned
by LifecycleInitialStateListener, and schemas that DO declare transitions are
still enforced (incl. their guards).

Found running procest's live-NC e2e suite on NC 32; verified the case
state-machine specs (advance + invalid-transition-blocked) now pass.
@rubenvdlinde
rubenvdlinde merged commit 36ad70c into development Jul 22, 2026
@rubenvdlinde
rubenvdlinde deleted the fix/lifecycle-empty-transitions-failclose branch July 22, 2026 22:07
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