Conversation
Workflow.version(changeId, minVersion, maxVersion) validated only its arguments; the version an instance actually persisted was never checked against the range the current code declares. If an author raised minVersion and pruned an old version's branch — or a deploy rolled maxVersion back — while instances holding that version were still in flight, those instances replayed with the stale value and silently took whatever the remaining code did with it. No error, no metric, no log. Add VersionGateEnforcer, applied to the value version() hands back — the only place the persisted version is observable. On replay the named checkpoint is returned without re-invoking the backing action (ActionExecutor.executeAction), so a check inside VersionGateHelpers.getVersion would never fire. When the stored version is outside [minVersion, maxVersion] the enforcer always logs at ERROR and emits the versionGate.storedVersionOutOfRange metric; when the enforce_version_gate_min_version feature gate is enabled it additionally fails the instance with a NonRetryableError (terminal ERROR state, no retry burn). - Both bounds are enforced: stored < minVersion (pruned branch) and stored > maxVersion (rollback). - Enforcement is opt-in, so upgrading is behavior-preserving; the metric and log make a premature minVersion raise discoverable before the throw is on. The enforcer is injected via SkipperInternalDeps, mirroring actionExecutor/skipperEngine/contextPropagator. Tests: VersionGateEnforcerTest covers the boundaries, both directions, both gate states, error type, message, and metric tags; BaseWorkflowIntegTest's version-gate suite adds below-min and above-max replay -> terminal ERROR, in-range replay -> normal completion, and gate-off -> detect-only. Docs: versioning.md now states that removing a version's path is detectable rather than silent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This branch has not been deployed
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.
Goal
Workflow.version(changeId, minVersion, maxVersion)validated only its arguments; the version aninstance actually persisted was never checked against the range the current code declares. If an
author raised
minVersionand pruned an old version's branch — or a deploy rolledmaxVersionback — while instances holding that version were still in flight, those instances replayed with the
stale value and silently took whatever the remaining code did with it. No error, no metric, no log.
This makes that case fail loudly instead.
What changed
internal/VersionGateEnforcer, applied to the valueversion()hands back — the only placethe persisted version is observable. On replay the named checkpoint is returned without
re-invoking the backing action (
ActionExecutor.executeAction), so a check insideVersionGateHelpers.getVersionwould never fire.[minVersion, maxVersion]the enforcer always logs atERROR and emits the
versionGate.storedVersionOutOfRangemetric (tags:changeId,direction,enforced). WhenFeatureGate.Keys.ENFORCE_VERSION_GATE_MIN_VERSIONis enabled for the app itadditionally throws a
NonRetryableError, moving the instance to a terminalERRORstatewith no retry burn.
SkipperInternalDeps, mirroringactionExecutor/skipperEngine/contextPropagator.Key decisions
NonRetryableError→ terminalERRORwithout consuming the retry budget. Astale version is permanent, so a retryable error would burn budget for nothing.
stored < minVersion(pruned branch) andstored > maxVersion(rollback, the more common case).
behavior-preserving; the metric makes a premature
minVersionraise discoverable before the hardfailure is switched on per app.
Testing
VersionGateEnforcerTest: boundaries, both directions, both gate states, error type, message,metric tags.
BaseWorkflowIntegTestversion-gate suite: below-min and above-max replay → terminalERROR;in-range replay → normal completion; gate-off → detect-only.
./gradlew :testversion-gate suites green (VersionGateEnforcerTest6/6,WorkflowVersionGateTest3/3,
SqliteWorkflowIntegTestTestVersionGate 7/7), stable across reruns.Docs
versioning.mdnow documents that removing a version's path is detectable rather than silent.🤖 Generated with Claude Code