Add workflow drift safety (orphaned-state handling)#16
Conversation
Make the plugin safe when a workflow definition changes while records are in flight. Graceful degradation (always on, no config): - Orphaned states (left by a definition change) no longer throw on read or display. Add State::unknown()/isUnknown() and Definition::findState()/ resolveState(); the engine, behavior and helper degrade to a neutral state, and transitions on orphaned records return a clear blocked result. Opt-in version stamping (off by default): - New behavior config 'versioning' + 'versionField' stamps the definition hash onto a nullable entity column on transition and on new-entity save, skipping cleanly when the column is absent. - getVersionStamp()/isStale() expose drift per record. - getVersionHash() now fingerprints the full definition (version number, state and transition attributes), not just the state graph. Tooling: - workflow stamp backfills version stamps (--all, --dry-run). - workflow migrate re-stamps stale records and maps orphaned records forward (--map), refusing on unmapped orphans, running atomically, and resyncing state timeouts for migrated records. - workflow validate --check-data reports unversioned/stale records when versioning is enabled on the table. - stamp/migrate default the version column to the behavior configuration. Docs: README section, guide/versioning page (with sidebar entry), CLI reference, and comparison page.
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #16 +/- ##
============================================
+ Coverage 72.37% 72.96% +0.59%
- Complexity 1148 1188 +40
============================================
Files 62 63 +1
Lines 4090 4236 +146
============================================
+ Hits 2960 3091 +131
- Misses 1130 1145 +15 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR adds workflow “drift safety” so records don’t crash the engine/UI when their stored state no longer exists after a workflow definition change, and introduces opt-in per-record version stamping plus CLI tooling to detect/backfill/migrate drift.
Changes:
- Add
State::unknown()plusDefinition::findState()/resolveState()and update engine/behavior/helper codepaths to degrade gracefully for orphaned states. - Add opt-in version stamping (
versioning,versionField) inWorkflowBehavior, including new-entity stamping and stale detection helpers. - Add CLI tooling (
workflow stamp,workflow migrate) and extendworkflow validate --check-datato report version drift; update docs and tests accordingly.
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/TestCase/View/Helper/WorkflowHelperTest.php | Adds coverage for helper rendering/color behavior on orphaned states. |
| tests/TestCase/Model/Behavior/WorkflowBehaviorTest.php | Adds tests for orphaned-state safety and version stamping/staleness APIs. |
| tests/TestCase/Model/Behavior/WorkflowBehaviorPersistenceTest.php | Extends persistence schema to include version column. |
| tests/TestCase/Engine/StateMachineEngineTest.php | Adds tests ensuring engine blocks/returns empty transitions for orphaned states. |
| tests/TestCase/Engine/Definition/StateTest.php | Adds tests for the new State::unknown() and isUnknown(). |
| tests/TestCase/Engine/Definition/DefinitionTest.php | Adds tests for findState/resolveState and stronger version-hash sensitivity. |
| tests/TestCase/DatabaseTestCase.php | Extends shared test schema to include versioning columns. |
| tests/TestCase/Command/WorkflowValidateCommandTest.php | Adds integration tests for drift reporting gated by versioning config. |
| tests/TestCase/Command/WorkflowStampCommandTest.php | New tests for backfill stamping behavior and version-field resolution. |
| tests/TestCase/Command/WorkflowMigrateCommandTest.php | New tests for stale restamping, orphan mapping, timeout resync, and rollback on audit failure. |
| src/WorkflowPlugin.php | Registers the new workflow stamp and workflow migrate commands. |
| src/View/Helper/WorkflowHelper.php | Switches state lookups to resolveState() for orphan-safe rendering. |
| src/Model/Behavior/WorkflowBehavior.php | Adds versioning config, stamping on create/transition, and getVersionStamp()/isStale(). |
| src/Engine/StateMachineEngine.php | Makes can/apply/getAvailableTransitions orphan-safe with clear blocked result. |
| src/Engine/Definition/State.php | Adds unknown-state support (unknown flag, factory, accessor). |
| src/Engine/Definition/Definition.php | Adds non-throwing state resolution and expands version hash to include structural attributes. |
| src/Command/WorkflowValidateCommand.php | Extends --check-data validation to optionally report version drift when enabled on the table. |
| src/Command/WorkflowStampCommand.php | New command to backfill version stamps onto existing records. |
| src/Command/WorkflowMigrateCommand.php | New command to re-stamp stale records and map orphaned states forward with audit logging/timeout resync. |
| src/Command/VersionFieldOptionTrait.php | Shared option resolution for version-field across commands. |
| README.md | Documents drift safety, versioning enablement, and new CLI commands. |
| docs/reference/comparison.md | Updates feature comparison to include drift safety/versioning/migration tooling. |
| docs/reference/cli.md | Documents validate --check-data drift reporting and the new stamp/migrate commands. |
| docs/guide/versioning.md | New guide page describing drift, version stamping, and migration workflow. |
| docs/.vitepress/config.ts | Adds the versioning guide page to the sidebar. |
| .gitignore | Ignores a docs working directory. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… stamp - workflow migrate logs the human workflow version in workflow_transitions.workflow_version, consistent with normal transitions (the entity column keeps the structural hash for drift detection). - New entities are always stamped with the current version hash, overwriting any client-supplied value, since the column is plugin-managed metadata.
- workflow migrate now logs and syncs by $entity->get('id'), consistent with
TransitionLogger and TimeoutScheduler (previously used the table primary key,
which diverged and would break non-id keys).
- Select only the id column when migrating a state group to keep memory bounded;
the batch still runs in a single transaction for atomic all-or-nothing.
Per review, drop the opt-in per-record version stamping and its tooling in favor of what works out of the box with no column or config: - Keep graceful degradation: orphaned states never crash reads/display (State::unknown, Definition::findState/resolveState, engine/behavior/helper). - Keep orphan remediation: workflow migrate --map moves orphaned records forward (atomic, logs each move, resyncs timeouts), alongside the existing admin Orphans view and workflow validate --check-data. - Remove versioning/versionField config, version stamping, isStale/ getVersionStamp, workflow stamp, VersionFieldOptionTrait, validate drift reporting, and the full-definition getVersionHash change. Version stays a definition-level number (already logged); records carry no per-record version. Docs updated accordingly.
Summary
Makes the plugin safe when a workflow definition changes while records already exist — the "100 production records in different states, then we change the workflow" problem. It works out of the box: no configuration, no schema change, nothing to enable.
Graceful degradation (always on)
Records left in a state that no longer exists ("orphaned") no longer crash reads, display, or the admin UI — they render as a neutral "unknown" state, and transitioning one returns a clear blocked result instead of throwing.
State::unknown()/isUnknown(),Definition::findState()/resolveState()can()/apply()/getAvailableTransitions(),WorkflowBehavior::isFinal()/hasFlag(), andWorkflowHelper::stateBadge()/getStateColor()all degrade instead of throwinggetState()stays strict for genuine misconfiguration (unknown transition targets)Detection & remediation (zero-config)
Orphaned records are found by comparing each record's stored state to the current definition — no version tracking required.
/admin/workflow/orphans, with a sidebar count badge) andworkflow validate --check-datalist records in undefined statesworkflow migrate --map old:newmoves them forward headlessly: refuses to run if any orphaned state is unmapped, runs atomically (rolls back if an audit-log write fails), logs each move, and resyncs the target state's timeoutsOn versions
A definition keeps its
versionnumber (already recorded on every transition and shown byworkflow show); records carry no per-record version, and there is nothing to back-fill.null/absent simply means the baseline.Out of scope (documented future work)
Docs
README "Drift Safety" section, new
guide/versioning(Drift Safety) page wired into the sidebar, CLI reference, and the comparison page.