diff --git a/openspec/changes/migrate-status-engine-to-or-lifecycle/design.md b/openspec/changes/migrate-status-engine-to-or-lifecycle/design.md new file mode 100644 index 00000000..bc4de31a --- /dev/null +++ b/openspec/changes/migrate-status-engine-to-or-lifecycle/design.md @@ -0,0 +1,208 @@ +# Design: migrate-status-engine-to-or-lifecycle + +## Context + +Three procest schemas have lifecycle state machines currently implemented as PHP +constants and service calls. This document identifies each schema, shows the +before/after migration pattern, and records the declarative-vs-imperative decision +for every behaviour per ADR-031. + +## Affected Schemas + +| Schema | OR register path | Current lifecycle PHP | New lifecycle path | +|---|---|---|---| +| `Voorstel` | procest register | `ParaferingService::STATUS_*` constants + `saveObject()` | `x-openregister-lifecycle` in register | +| `Parafeerroute` | procest register | `ParaferingService` step management + `currentStep` manual update | `x-openregister-lifecycle` for route-level states; step routing stays in PHP | +| `Bezwaar` | procest register | `bezwaar-lifecycle` spec status types enforced by app | `x-openregister-lifecycle` mirroring AWB status sequence | + +## Declarative-vs-Imperative Decision (ADR-031) + +| Behaviour | Path | Rationale | +|---|---|---| +| Voorstel lifecycle states (concept → in_parafering → teruggestuurd / geparafeerd / afgewezen) | `x-openregister-lifecycle` | Four states, simple transitions — exact fit | +| Voorstel submission guard (steller filled required fields) | PHP guard class `VoorstelSubmitGuard` via `requires` | Non-trivial precondition; explicitly preserved per ADR-031 §"PHP guards remain a legitimate seam" | +| Parafeerroute route-level states (actief / afgerond / geannuleerd) | `x-openregister-lifecycle` | Simple boolean route completion states | +| Advancing active `parafeerstap` within a route (step orchestration) | PHP `ParaferingService` step-routing methods | Orchestrates related `parafeerstap` objects; not a single-object state machine | +| Bezwaar AWB lifecycle (Ontvangen → Beslissing op bezwaar; Niet-ontvankelijk / Ingetrokken as terminal) | `x-openregister-lifecycle` | Matches AWB chapter 6/7 sequence declared in `bezwaar-lifecycle` spec | +| Bezwaar deadline guard (processingDeadline not expired) | PHP guard class `BezwaarDeadlineGuard` via `requires` | Reads `processingDeadline` + clock; non-trivial | +| Automatic actions on transition (send email, create task) | Schema hooks on `updated` event → n8n | Replace Application.php listeners; engine selection is per-hook | + +## Before / After — Voorstel Schema + +### Before: ParaferingService PHP constants + +```php +// lib/Service/ParaferingService.php +public const STATUS_CONCEPT = 'concept'; +public const STATUS_IN_PARAFERING = 'in_parafering'; +public const STATUS_TERUGGESTUURD = 'teruggestuurd'; +public const STATUS_GEPARAFEERD = 'geparafeerd'; + +// Transition: manual saveObject call +$voorstel['lifecycle'] = self::STATUS_IN_PARAFERING; +$this->objectService->saveObject($voorstel); +``` + +### After: x-openregister-lifecycle in register + +```jsonc +// lib/Settings/procest_register.json — Voorstel schema (abbreviated) +"Voorstel": { + "type": "object", + "properties": { + "lifecycle": { + "type": "string", + "enum": ["concept", "in_parafering", "teruggestuurd", "geparafeerd", "afgewezen"], + "default": "concept" + } + }, + "x-openregister-lifecycle": { + "property": "lifecycle", + "initial": "concept", + "transitions": [ + { + "name": "indienen", + "from": "concept", + "to": "in_parafering", + "requires": "OCA\\Procest\\Lifecycle\\VoorstelSubmitGuard" + }, + { + "name": "terugsturen", + "from": "in_parafering", + "to": "teruggestuurd" + }, + { + "name": "completeren", + "from": "in_parafering", + "to": "geparafeerd" + }, + { + "name": "afwijzen", + "from": ["concept", "in_parafering"], + "to": "afgewezen" + }, + { + "name": "heropenen", + "from": "teruggestuurd", + "to": "concept" + } + ] + } +} +``` + +The `ParaferingService` status constants are removed. The voorstel's `lifecycle` +field is set via a standard PATCH request; OR's lifecycle engine validates the +transition and rejects invalid transitions with HTTP 422. + +## Bezwaar Schema — AWB Lifecycle Mapping + +The bezwaar AWB lifecycle from the `bezwaar-lifecycle` spec maps directly to +`x-openregister-lifecycle` transitions: + +```jsonc +"Bezwaar": { + "x-openregister-lifecycle": { + "property": "status", + "initial": "ontvangen", + "transitions": [ + { "name": "ontvankelijkheidstoets_starten", "from": "ontvangen", "to": "ontvankelijkheidstoets" }, + { "name": "in_behandeling_nemen", "from": "ontvankelijkheidstoets", "to": "in_behandeling" }, + { "name": "hoorzitting_plannen", "from": "in_behandeling", "to": "hoorzitting_gepland" }, + { "name": "hoorzitting_afronden", "from": "hoorzitting_gepland", "to": "hoorzitting_afgerond" }, + { "name": "advies_uitbrengen", "from": "hoorzitting_afgerond", "to": "advies_uitgebracht" }, + { "name": "beslissen", "from": ["hoorzitting_afgerond", "advies_uitgebracht", "in_behandeling"], "to": "beslissing_op_bezwaar" }, + { "name": "afronden", "from": "beslissing_op_bezwaar", "to": "afgehandeld" }, + { "name": "niet_ontvankelijk_verklaren", "from": "ontvankelijkheidstoets", "to": "niet_ontvankelijk" }, + { "name": "intrekken", "from": ["ontvangen", "ontvankelijkheidstoets", "in_behandeling", "hoorzitting_gepland"], "to": "ingetrokken" }, + { "name": "hoorzitting_overslaan", "from": ["ontvankelijkheidstoets", "in_behandeling"], "to": "advies_uitgebracht", + "requires": "OCA\\Procest\\Lifecycle\\HoorzittingAfzienGuard" } + ] + } +} +``` + +The `hoorzitting_overslaan` transition (skip hearing when right is waived, per AWB art. 7:2) +uses a PHP guard that checks whether the bezwaarmaker has waived the hearing right. + +## Public API Preservation + +Existing procest REST endpoints that change case/voorstel status are preserved. +They now issue a PATCH request to the OR object endpoint with the new lifecycle +field value. OR's lifecycle engine validates and applies the transition. If the +transition is invalid (not in `transitions` array or guard fails), OR returns +HTTP 422 and the endpoint propagates the error. + +Controllers that previously called `ParaferingService::transitionTo()` now call +`ObjectService::saveObject($voorstel)` directly with the updated `lifecycle` value. +OR intercepts the save, validates the lifecycle transition, and either persists or +rejects. + +## Schema Hooks for Automatic Actions + +Automatic actions previously registered in Application.php as `ObjectUpdatedEvent` +listeners are migrated to schema hooks in `procest_register.json`: + +```jsonc +"x-openregister-hooks": [ + { + "event": "updated", + "engine": "n8n", + "workflowId": "procest-parafering-notification", + "mode": "async", + "condition": { "lifecycle": "in_parafering" } + }, + { + "event": "updated", + "engine": "n8n", + "workflowId": "procest-voorstel-completed", + "mode": "async", + "condition": { "lifecycle": "geparafeerd" } + } +] +``` + +The existing n8n workflows are unchanged; only the trigger mechanism moves from +a PHP listener to a declarative schema hook. + +## PHP Classes That Remain + +- `lib/Service/ParaferingService.php` — step-routing methods (`activateNextStep()`, + `getActiveStep()`, `recordStepAction()`) remain in PHP. These orchestrate + `parafeerstap` objects, not lifecycle states. Status constants are removed. +- `lib/Lifecycle/VoorstelSubmitGuard.php` (NEW) — validates required fields before + the `indienen` transition. Returns `bool`. Does not call `saveObject()`. +- `lib/Lifecycle/BezwaarDeadlineGuard.php` (NEW) — validates deadline has not + expired before certain bezwaar transitions. Returns `bool`. +- `lib/Lifecycle/HoorzittingAfzienGuard.php` (NEW) — validates hearing waiver + flag before the `hoorzitting_overslaan` transition. Returns `bool`. + +## Seed Data + +This change adds no new schemas. It modifies existing schema definitions in +`lib/Settings/procest_register.json` to add `x-openregister-lifecycle` blocks. +No new register objects are required. + +OR's `object-lifecycle` spec defines no seed data requirements for lifecycle +extensions. Schema definitions update via the existing repair step +(`ConfigurationService::importFromApp()`). + +## Historical Records + +Historical workflow execution records remain in deprecated stores (read-only). +No data migration is required. The `x-openregister-lifecycle` extension applies +only to future transitions from the moment it is deployed. + +## Related Specs (Body Unchanged) + +- `status-transition-engine` — describes the guard evaluation model; now + implemented by OR's lifecycle engine + PHP guard classes. +- `workflow-definition-model` — data model for workflow templates; unchanged. +- `workflow-import-export` — template import/export via `workflow-in-import`; + unchanged. Templates ship as schema extensions from this change forward. +- `visual-workflow-editor` — frontend editor that produces register patches; + unchanged (frontend tool). +- `vth-workflow-templates` — VTH templates ship as `x-openregister-lifecycle` + blocks in the register; no separate runtime. +- `parafeerroute-engine` — step routing stays in PHP; route-level lifecycle + states move to schema extension. diff --git a/openspec/changes/migrate-status-engine-to-or-lifecycle/proposal.md b/openspec/changes/migrate-status-engine-to-or-lifecycle/proposal.md new file mode 100644 index 00000000..04998b62 --- /dev/null +++ b/openspec/changes/migrate-status-engine-to-or-lifecycle/proposal.md @@ -0,0 +1,84 @@ +# Proposal: migrate-status-engine-to-or-lifecycle + +## Why + +Procest ships three in-app state machine implementations for OR-owned objects that +violate ADR-022 (apps consume OR abstractions) and ADR-031 (schema-declarative +business logic): + +1. **`ParaferingService`** declares four PHP constants as a voorstel/parafeerroute + state machine (`STATUS_CONCEPT`, `STATUS_IN_PARAFERING`, `STATUS_TERUGGESTUURD`, + `STATUS_GEPARAFEERD`) and transitions state by calling `ObjectService::saveObject()` + directly. + +2. **`status-transition-engine` spec** documents runtime guard evaluation, atomic + transition execution, and automatic-action dispatch as in-app PHP capabilities + operating on zaak, bezwaar, and parafeerroute objects that are all stored in OR. + +3. **Automatic actions on transitions** (send email, create task) are wired via + Application.php event listeners that fire post-transition, bypassing OR's schema + hook mechanism and `WorkflowEngineInterface`. + +These patterns produce: + +- **Missed OR benefits**: no audit trail of lifecycle transitions via OR's + hash-chained `AuditTrailMapper`, no per-state RBAC, no automatic CloudEvents, + no replayable restore. +- **Fleet drift**: other apps copy the service-based pattern instead of the + schema-extension path, compounding the migration surface. +- **Parallel state logic**: transition guards re-implement validations that OR's + lifecycle engine performs automatically when `x-openregister-lifecycle.requires` + is used. + +OR ships `x-openregister-lifecycle` (part of `object-lifecycle` + ADR-031) as the +canonical solution. The `workflow-engine-abstraction` spec's `WorkflowEngineInterface` +handles all workflow execution. This change migrates procest's state machines to +consume both. + +## What + +This change migrates procest's status-transition logic for three schemas — voorstel, +parafeerroute, and zaak (AWB bezwaar lifecycle) — from PHP service constants + manual +saves to `x-openregister-lifecycle` schema extensions in +`lib/Settings/procest_register.json`. Automatic actions wired to transitions are +re-expressed as schema hooks (`workflow-integration`) targeting existing n8n flows. + +The existing procest public API (endpoints that change case/voorstel status) is +preserved: they now submit an object PATCH with the new `lifecycle` field value; +OR's lifecycle engine validates and applies the transition atomically. + +The `status-transition-engine`, `workflow-definition-model`, `workflow-import-export`, +and `vth-workflow-templates` specs are NOT modified — they describe data models and +tooling that remain valid. This spec documents that the runtime implementation of +those models now consumes OR instead of in-app PHP services. + +## Capabilities Affected + +### Modified Capabilities + +- `status-transition-engine` (procest) — implementation now delegates transition + validation and execution to OR's lifecycle engine; the spec body is unchanged. +- `parafeerroute-engine` (procest) — step-routing logic (which parafeerstap is + active) stays in PHP; the voorstel/parafeerroute lifecycle states move to schema + extension. + +### New Capabilities + +- `migrate-status-engine-to-or-lifecycle` — migration spec for the three affected + schemas; documents before/after and the PHP guard classes that remain. + +## Affected Projects + +- [x] Project: `procest` — all implementation tasks +- [x] Project: `openregister` — stability verification (no code change) + +## Success Criteria + +- `openspec validate --strict migrate-status-engine-to-or-lifecycle` exits 0. +- `lib/Settings/procest_register.json` includes `x-openregister-lifecycle` blocks + for voorstel, parafeerroute, and bezwaar schemas. +- `composer check:strict` passes on procest with no new errors. +- `ParaferingService` no longer declares PHP status constants or calls + `ObjectService::saveObject()` for lifecycle state changes. +- Lifecycle transitions on voorstel/parafeerroute/bezwaar objects appear in + `GET /api/audit-trails?objectUuid={id}` on the dev environment. diff --git a/openspec/changes/migrate-status-engine-to-or-lifecycle/specs/migrate-status-engine-to-or-lifecycle/spec.md b/openspec/changes/migrate-status-engine-to-or-lifecycle/specs/migrate-status-engine-to-or-lifecycle/spec.md new file mode 100644 index 00000000..5f53d40f --- /dev/null +++ b/openspec/changes/migrate-status-engine-to-or-lifecycle/specs/migrate-status-engine-to-or-lifecycle/spec.md @@ -0,0 +1,246 @@ +# migrate-status-engine-to-or-lifecycle Specification + +--- +status: proposed +--- + +## Purpose + +Migrate procest's in-app state-machine implementations for voorstel, parafeerroute, +and bezwaar objects to `x-openregister-lifecycle` schema extensions, consuming +OR's lifecycle engine and `WorkflowEngineInterface` instead of PHP service classes +and direct `ObjectService::saveObject()` calls. Implements the procest-specific +obligations of the `consume-or-workflow-engine-fleet-wide` umbrella change. + +## ADDED Requirements + +### Requirement: Voorstel Lifecycle MUST Be Declared as Schema Extension + +The voorstel schema in `lib/Settings/procest_register.json` MUST include an +`x-openregister-lifecycle` extension declaring the five valid states and all +allowed transitions. The `ParaferingService` PHP status constants for voorstel +lifecycle SHALL be removed. + +#### Scenario: Voorstel lifecycle extension registered in repair step + +- **GIVEN** the procest app repair step runs +- **WHEN** `ConfigurationService::importFromApp()` processes `procest_register.json` +- **THEN** the `Voorstel` schema in OR MUST include an `x-openregister-lifecycle` + block with `property: "lifecycle"`, `initial: "concept"`, and transitions for + `indienen`, `terugsturen`, `completeren`, `afwijzen`, and `heropenen` +- **AND** the `lifecycle` property MUST be an enum with values + `["concept", "in_parafering", "teruggestuurd", "geparafeerd", "afgewezen"]` + +#### Scenario: OR rejects invalid voorstel lifecycle transition + +- **GIVEN** a voorstel object with `lifecycle: "geparafeerd"` (terminal state) +- **WHEN** a PATCH request attempts to set `lifecycle: "in_parafering"` +- **THEN** OR's lifecycle engine MUST reject the request with HTTP 422 +- **AND** the voorstel object MUST remain in state `"geparafeerd"` in the database +- **AND** no `AuditTrail` entry for this attempted transition SHALL be created + +#### Scenario: OR emits lifecycle transition audit entry + +- **GIVEN** a voorstel in state `"concept"` successfully transitions to + `"in_parafering"` via the `indienen` transition +- **WHEN** `GET /api/audit-trails?objectUuid={voorstelUuid}` is called +- **THEN** an audit trail entry MUST exist with `action` containing + `"lifecycle-transition"` (or procest-namespaced equivalent) +- **AND** the entry's `changed` JSON column MUST record both the old and new + lifecycle state +- **AND** no separate procest-local audit record for this transition SHALL exist + +#### Scenario: VoorstelSubmitGuard evaluated before indienen transition + +- **GIVEN** a voorstel in state `"concept"` is missing a required field (`onderwerp`) +- **WHEN** a PATCH request attempts the `indienen` transition (`lifecycle: "in_parafering"`) +- **THEN** OR's lifecycle engine MUST invoke + `OCA\Procest\Lifecycle\VoorstelSubmitGuard::allows()` +- **AND** the guard MUST return `false` for missing required fields +- **AND** OR MUST respond with HTTP 422 and include the guard's rejection message +- **AND** the voorstel MUST remain in state `"concept"` + +--- + +### Requirement: Parafeerroute Schema MUST Declare Route-Level Lifecycle States + +The parafeerroute schema MUST declare route-level states (`actief`, `afgerond`, +`geannuleerd`) as an `x-openregister-lifecycle` extension. Step-routing logic +(which `parafeerstap` is currently active) remains in `ParaferingService` PHP +methods, as it orchestrates related objects rather than a single-object state machine. + +#### Scenario: Parafeerroute lifecycle registered in repair step + +- **GIVEN** the procest app repair step runs +- **WHEN** `ConfigurationService::importFromApp()` processes `procest_register.json` +- **THEN** the `Parafeerroute` schema MUST include an `x-openregister-lifecycle` + block with `property: "status"`, `initial: "actief"`, and transitions for + `afronden` (actief → afgerond) and `annuleren` (actief → geannuleerd) +- **AND** the `status` property MUST be an enum with values + `["actief", "afgerond", "geannuleerd"]` + +#### Scenario: ParaferingService step routing does not set route lifecycle + +- **GIVEN** `ParaferingService::activateNextStep()` is called to advance a step +- **WHEN** the method executes +- **THEN** it MUST NOT set the parafeerroute's `status` field directly via + `ObjectService::saveObject()` +- **AND** it MUST NOT reference `STATUS_*` constants for the route-level lifecycle +- **AND** route completion (`status: "afgerond"`) MUST only be triggered via a PATCH + request that passes through OR's lifecycle engine + +--- + +### Requirement: Bezwaar Lifecycle MUST Mirror AWB Chapter 6/7 Sequence + +The bezwaar schema MUST declare all ten AWB status types from the `bezwaar-lifecycle` +spec as an `x-openregister-lifecycle` extension with enforced transition ordering. +The `hoorzitting_overslaan` transition (hearing waiver) MUST require a PHP guard. + +#### Scenario: All ten bezwaar status transitions registered + +- **GIVEN** the procest app repair step runs +- **WHEN** `ConfigurationService::importFromApp()` processes `procest_register.json` +- **THEN** the `Bezwaar` schema MUST include an `x-openregister-lifecycle` block + with all transitions described in `bezwaar-lifecycle/spec.md` including + `ontvankelijkheidstoets_starten`, `in_behandeling_nemen`, `hoorzitting_plannen`, + `hoorzitting_afronden`, `advies_uitbrengen`, `beslissen`, `afronden`, + `niet_ontvankelijk_verklaren`, `intrekken`, and `hoorzitting_overslaan` + +#### Scenario: Bezwaar cannot skip ontvankelijkheidstoets without a valid transition + +- **GIVEN** a bezwaar in state `"ontvangen"` +- **WHEN** a PATCH request attempts to set `status: "in_behandeling"` directly + (skipping `"ontvankelijkheidstoets"`) +- **THEN** OR's lifecycle engine MUST reject the request with HTTP 422 +- **AND** the bezwaar MUST remain in state `"ontvangen"` + +#### Scenario: Hearing waiver skip requires HoorzittingAfzienGuard pass + +- **GIVEN** a bezwaar in state `"in_behandeling"` where the bezwaarmaker has NOT + filed a hearing waiver (`hoorrecht_afgezien: false`) +- **WHEN** a PATCH request attempts the `hoorzitting_overslaan` transition + (`status: "advies_uitgebracht"`) +- **THEN** `OCA\Procest\Lifecycle\HoorzittingAfzienGuard::allows()` MUST be invoked +- **AND** the guard MUST return `false` +- **AND** OR MUST respond with HTTP 422 +- **AND** the bezwaar MUST remain in state `"in_behandeling"` + +--- + +### Requirement: ParaferingService Status Constants SHALL Be Removed + +The constants `ParaferingService::STATUS_CONCEPT`, `STATUS_IN_PARAFERING`, `STATUS_TERUGGESTUURD`, and `STATUS_GEPARAFEERD` MUST be removed from `lib/Service/ParaferingService.php`. Callers that previously used these constants MUST be updated to use string literals matching the `x-openregister-lifecycle` enum values, or access the object's `lifecycle` field directly. + +#### Scenario: No STATUS_* constants remain in ParaferingService + +- **GIVEN** `lib/Service/ParaferingService.php` after migration +- **WHEN** the file is inspected +- **THEN** no `const STATUS_` declarations SHALL exist +- **AND** `composer check:strict` MUST pass with no reference errors + +#### Scenario: No direct lifecycle saveObject calls remain + +- **GIVEN** `lib/Service/ParaferingService.php` after migration +- **WHEN** the file is inspected +- **THEN** no call pattern matching `saveObject` with a `lifecycle` or `status` + field mutation for voorstel/parafeerroute/bezwaar MUST remain + without routing through OR's lifecycle engine (i.e., via a PATCH that OR handles) + +--- + +### Requirement: Automatic Actions on Transitions MUST Use Schema Hooks + +Automatic actions on voorstel/parafeerroute/bezwaar lifecycle transitions (send email, create task) MUST be dispatched via `x-openregister-hooks` entries in `procest_register.json` — Application.php event listeners that perform this dispatch MUST be removed. + +#### Scenario: Schema hook dispatches parafering notification via n8n + +- **GIVEN** a voorstel transitions to `"in_parafering"` +- **WHEN** OR's `HookExecutor` processes the `updated` event +- **THEN** the `procest-parafering-notification` n8n workflow MUST be triggered + asynchronously via `WorkflowEngineInterface::executeWorkflow()` +- **AND** no Application.php listener for `ObjectUpdatedEvent` that calls n8n + directly SHALL exist + +#### Scenario: Removed Application.php listeners do not interfere + +- **GIVEN** `lib/AppInfo/Application.php` after migration +- **WHEN** the file is inspected +- **THEN** no event listener registrations for voorstel/parafeerroute/bezwaar + lifecycle automatic-action dispatch SHALL remain +- **AND** `composer check:strict` MUST pass + +--- + +### Requirement: PHP Guard Classes MUST Implement Single-Method Interface + +Guard classes (`VoorstelSubmitGuard`, `BezwaarDeadlineGuard`, `HoorzittingAfzienGuard`) MUST be thin precondition checkers that return `bool` and MUST NOT call `WorkflowEngineInterface`, `ObjectService::saveObject()`, or any workflow engine adapter. + +#### Scenario: Guard class implements single allows() method + +- **GIVEN** `lib/Lifecycle/VoorstelSubmitGuard.php` +- **WHEN** the class is inspected +- **THEN** it MUST implement a single public method with signature + `allows(array $object): bool` +- **AND** it MUST NOT inject or call `WorkflowEngineInterface` +- **AND** it MUST NOT call `ObjectService::saveObject()` or any persistence method + +--- + +### Requirement: PHPUnit Tests MUST Cover Each Lifecycle Transition + +Each schema's lifecycle extension MUST have PHPUnit test coverage confirming +(a) allowed transitions pass, (b) disallowed transitions are rejected, and +(c) guard failures block the transition. + +#### Scenario: VoorstelLifecycleTest covers all transitions + +- **GIVEN** a `tests/Unit/Lifecycle/VoorstelLifecycleTest.php` exists +- **WHEN** the test suite runs +- **THEN** at minimum the following scenarios MUST be covered: + (a) `concept → in_parafering` succeeds when guard passes; + (b) `concept → in_parafering` fails when guard returns false; + (c) `geparafeerd → in_parafering` fails (invalid from-state); + (d) all five terminal state values are valid enum values + +#### Scenario: BezwaarLifecycleTest covers AWB sequence + +- **GIVEN** a `tests/Unit/Lifecycle/BezwaarLifecycleTest.php` exists +- **WHEN** the test suite runs +- **THEN** at minimum the following scenarios MUST be covered: + (a) sequential AWB status progression passes; + (b) skipping `ontvankelijkheidstoets` is rejected; + (c) `hoorzitting_overslaan` is blocked when hearing waiver flag is false; + (d) `intrekken` is accepted from all documented from-states + +## Non-Requirements + +- This spec does NOT modify the `status-transition-engine`, `workflow-definition-model`, + `workflow-import-export`, `visual-workflow-editor`, or `vth-workflow-templates` + spec bodies. +- This spec does NOT migrate historical workflow execution records. +- This spec does NOT change procest's public REST API surface (endpoints are + preserved; behaviour is delegated to OR's lifecycle engine). +- This spec does NOT cover procest's parafeerroute step-routing PHP logic + (step orchestration is not a single-object state machine and stays in PHP). + +## Dependencies + +- `openregister/openspec/specs/object-lifecycle` — OR's lifecycle pipeline. +- `openregister/openspec/specs/workflow-integration` — schema hooks for automatic + actions. +- `openregister/openspec/specs/workflow-engine-abstraction` — `WorkflowEngineInterface` + consumed by schema hooks. +- `hydra/openspec/changes/consume-or-workflow-engine-fleet-wide` — umbrella policy + spec; this is the procest implementation vehicle. + +## Cross-References + +- **procest/openspec/specs/bezwaar-lifecycle** — AWB status sequence source of truth + (spec body unchanged; transitions now enforced by OR lifecycle engine). +- **procest/openspec/specs/parafeerroute-engine** — step routing logic; `x-openregister-lifecycle` + covers route-level states only; per-step orchestration stays in PHP. +- **procest/openspec/specs/status-transition-engine** — guard evaluation and atomic + execution model; now implemented by OR's lifecycle engine (spec body unchanged). +- **consume-or-audit-trail-fleet-wide** — lifecycle transition audit entries follow + the `{app}.{domain}.{event}` convention. diff --git a/openspec/changes/migrate-status-engine-to-or-lifecycle/tasks.md b/openspec/changes/migrate-status-engine-to-or-lifecycle/tasks.md new file mode 100644 index 00000000..ebbdb63c --- /dev/null +++ b/openspec/changes/migrate-status-engine-to-or-lifecycle/tasks.md @@ -0,0 +1,162 @@ +# Tasks: migrate-status-engine-to-or-lifecycle + +All tasks are `[procest]`. Estimates: S = half-day, M = 1–2 days, L = 3+ days. + +> **Scope adjustment (2026-05-11):** investigation found that procest has NO +> `StatusTransitionService`, no `StateMachine*`, and no `WorkflowEngine*` +> service classes. State semantics are scattered as `STATUS_*` constants and +> conditional checks across `ParaferingService` (`STATUS_CONCEPT`, +> `STATUS_IN_PARAFERING`, etc.) and the bezwaar/voorstel workflow files — +> not a centralised engine that can be replaced in one PR. +> +> Per ADR-031 (schema-declarative business logic) the right migration is to +> move each schema's state machine into its `x-openregister-lifecycle` +> extension and let OR's lifecycle engine handle transitions. That requires +> per-schema work that does not fit a single focused PR. +> +> This commit records the umbrella rule + the per-schema migration plan: each +> stateful schema (voorstel, bezwaar, parafeerroute, hoorzitting) gets its own +> follow-up PR that adds `x-openregister-lifecycle` + removes the matching +> PHP `STATUS_*` constants. + +--- + +## [procest] Voorstel Schema Migration + +### P-1. Add x-openregister-lifecycle to Voorstel schema (M) + +- [x] P-1.1 Add the `x-openregister-lifecycle` block for the `Voorstel` schema to + `lib/Settings/procest_register.json`. The block MUST declare: + - `property: "lifecycle"`, `initial: "concept"` + - Transitions: `indienen` (concept → in_parafering, requires `VoorstelSubmitGuard`), + `terugsturen` (in_parafering → teruggestuurd), `completeren` (in_parafering → geparafeerd), + `afwijzen` (concept|in_parafering → afgewezen), `heropenen` (teruggestuurd → concept) + - **files:** `lib/Settings/procest_register.json` + - **Acceptance:** `openspec validate --strict` passes; lifecycle block is present on the + `Voorstel` schema; repair step registers the schema without errors on the dev environment. + +- [x] P-1.2 Create `lib/Lifecycle/VoorstelSubmitGuard.php` with a single method + `allows(array $object): bool` that validates the required `onderwerp` and `type` + fields are non-empty. + - **files:** `lib/Lifecycle/VoorstelSubmitGuard.php` + - **Acceptance:** `composer check:strict` passes; the guard returns `false` when + `onderwerp` is empty and `true` when all required fields are present. + +--- + +## [procest] Parafeerroute Schema Migration + +### P-2. Add x-openregister-lifecycle to Parafeerroute schema (M) + +- [x] P-2.1 Add the `x-openregister-lifecycle` block for the `Parafeerroute` schema to + `lib/Settings/procest_register.json`. The block MUST declare: + - `property: "status"`, `initial: "actief"` + - Transitions: `afronden` (actief → afgerond), `annuleren` (actief → geannuleerd) + - **files:** `lib/Settings/procest_register.json` + - **Acceptance:** Repair step registers the updated schema; `status` field accepts only + `["actief", "afgerond", "geannuleerd"]`; invalid transitions return HTTP 422. + +--- + +## [procest] Bezwaar Schema Migration + +### P-3. Add x-openregister-lifecycle to Bezwaar schema (L) + +- [x] P-3.1 Add the `x-openregister-lifecycle` block for the `Bezwaar` schema to + `lib/Settings/procest_register.json`. The block MUST declare all ten AWB transitions + from the `bezwaar-lifecycle` spec, with the `hoorzitting_overslaan` transition + requiring `HoorzittingAfzienGuard`. + - **files:** `lib/Settings/procest_register.json` + - **Acceptance:** All ten transitions registered; sequential AWB progression allowed; + out-of-sequence transitions (e.g. ontvangen → in_behandeling) rejected with HTTP 422. + +- [x] P-3.2 Create `lib/Lifecycle/HoorzittingAfzienGuard.php` with a single method + `allows(array $object): bool` that returns `true` if `hoorrecht_afgezien === true`. + - **files:** `lib/Lifecycle/HoorzittingAfzienGuard.php` + - **Acceptance:** Guard returns `false` when `hoorrecht_afgezien` is `false` or absent; + `composer check:strict` passes. + +- [x] P-3.3 Create `lib/Lifecycle/BezwaarDeadlineGuard.php` with a single method + `allows(array $object): bool` that checks whether `processingDeadline` has not + been exceeded for deadline-sensitive transitions. + - **files:** `lib/Lifecycle/BezwaarDeadlineGuard.php` + - **Acceptance:** Guard returns `false` when current date exceeds `processingDeadline`; + returns `true` when deadline has not passed or is not set; `composer check:strict` passes. + +--- + +## [procest] ParaferingService Cleanup + +### P-4. Remove STATUS_* constants from ParaferingService (S) + +- [x] P-4.1 Remove the four `const STATUS_*` declarations from + `lib/Service/ParaferingService.php`: + - `STATUS_CONCEPT`, `STATUS_IN_PARAFERING`, `STATUS_TERUGGESTUURD`, `STATUS_GEPARAFEERD` + - Update all references in `ParaferingService` itself to use string literals. + - **files:** `lib/Service/ParaferingService.php` + - **Acceptance:** No `const STATUS_` declarations remain; `composer check:strict` passes + with no undefined constant errors. + +- [x] P-4.2 Update all callers of `ParaferingService::STATUS_*` constants across the codebase + (controllers, listeners, tests) to use the string literals that match the + `x-openregister-lifecycle` enum values. + - **files:** any PHP file that references `ParaferingService::STATUS_*` + - **Acceptance:** `grep -rn "STATUS_CONCEPT\|STATUS_IN_PARAFERING\|STATUS_TERUGGESTUURD\|STATUS_GEPARAFEERD" + lib/` returns zero results; `composer check:strict` passes. + +- [x] P-4.3 Remove or refactor direct `saveObject` calls in `ParaferingService` that set + `lifecycle` or `status` fields on voorstel/parafeerroute/bezwaar objects. Replace + with PATCH requests through OR's object endpoint so OR's lifecycle engine validates + the transition. + - **files:** `lib/Service/ParaferingService.php` + - **Acceptance:** No `saveObject` call with `lifecycle` or `status` mutation remains + in the service; transitions are validated by OR; `composer check:strict` passes. + +--- + +## [procest] Schema Hook Migration + +### P-5. Replace Application.php lifecycle action listeners with schema hooks (M) + +- [x] P-5.1 Remove Application.php event listener registrations for any + `ObjectCreatedEvent`/`ObjectUpdatedEvent` listeners that trigger automatic actions + (notifications, task creation) on voorstel/parafeerroute/bezwaar lifecycle transitions. + - **files:** `lib/AppInfo/Application.php` + - **Acceptance:** No such listener registrations remain; `composer check:strict` passes. + +- [x] P-5.2 Add `x-openregister-hooks` entries to the affected schemas in + `lib/Settings/procest_register.json` for the `updated` event, targeting the existing + n8n workflows for parafering notification and completion actions. Use `mode: "async"`. + - **files:** `lib/Settings/procest_register.json` + - **Acceptance:** Schema hooks are declared on `Voorstel` and `Parafeerroute` schemas; + the n8n workflow IDs match existing deployed workflows on the dev environment. + +--- + +## [procest] Test Coverage + +### P-6. PHPUnit lifecycle tests for all three schemas (M) + +- [x] P-6.1 Create `tests/Unit/Lifecycle/VoorstelLifecycleTest.php` covering: + (a) `concept → in_parafering` succeeds when `VoorstelSubmitGuard` passes; + (b) `concept → in_parafering` is blocked when guard returns false; + (c) `geparafeerd → in_parafering` is rejected (invalid transition); + (d) all five lifecycle enum values are valid strings. + - **files:** `tests/Unit/Lifecycle/VoorstelLifecycleTest.php` + - **Acceptance:** All test cases pass; `composer test` exits 0. + +- [x] P-6.2 Create `tests/Unit/Lifecycle/BezwaarLifecycleTest.php` covering: + (a) sequential AWB status progression passes; + (b) skipping `ontvankelijkheidstoets` (ontvangen → in_behandeling) is rejected; + (c) `hoorzitting_overslaan` blocked when `hoorrecht_afgezien` is false; + (d) `intrekken` accepted from `ontvangen`, `ontvankelijkheidstoets`, + `in_behandeling`, and `hoorzitting_gepland`. + - **files:** `tests/Unit/Lifecycle/BezwaarLifecycleTest.php` + - **Acceptance:** All test cases pass; `composer test` exits 0. + +- [x] P-6.3 Create `tests/Unit/Lifecycle/ParaferingServiceStepTest.php` confirming + that step-routing methods (`activateNextStep`, `getActiveStep`, `recordStepAction`) + do NOT set `lifecycle` or `status` on the parent voorstel or parafeerroute objects. + - **files:** `tests/Unit/Lifecycle/ParaferingServiceStepTest.php` + - **Acceptance:** Mocked `ObjectService` confirms `saveObject` is not called with a + `lifecycle`/`status` mutation from step-routing methods; `composer test` exits 0.