Stage Lifecycle¶
This document describes the state machine that governs staging operations in suve. The staging system uses a Redux-like pattern with pure reducer functions for predictable state transitions.
Overview¶
The staging system manages two types of state:
- Entry State: Tracks value changes (create/update/delete operations)
- Tag State: Tracks tag modifications (add/remove operations)
Entry State Machine¶
States¶
| State | Description |
|---|---|
NotStaged |
No pending changes for this resource |
Create |
Resource will be created (doesn't exist on the remote) |
Update |
Resource value will be modified |
Delete |
Resource will be deleted |
State Diagram¶
stateDiagram-v2
[*] --> NotStaged
NotStaged --> Create: add (resource NOT on remote)
NotStaged --> Update: edit (value != remote)
NotStaged --> NotStaged: edit (value = remote) [auto-skip]
NotStaged --> Delete: delete (resource on remote)
Create --> Create: add (update draft)
Create --> Create: edit (update draft)
Create --> NotStaged: delete [also unstage tags]
Create --> NotStaged: reset
Update --> Update: edit (value != remote)
Update --> NotStaged: edit (value = remote) [auto-unstage]
Update --> Delete: delete
Update --> NotStaged: reset
Delete --> NotStaged: reset
Delete --> Delete: delete [no-op]
note right of NotStaged
add (resource on remote) -> ERROR
delete (resource NOT on remote) -> ERROR
tag/untag (resource NOT on remote) -> ERROR
end note
note right of Delete
edit -> ERROR
add -> ERROR
tag/untag -> ERROR
end note
Transition Rules¶
| Current State | Action | Condition | New State | Notes |
|---|---|---|---|---|
| NotStaged | add |
resource NOT on remote | Create | Stage new resource |
| NotStaged | add |
resource on remote | ERROR | Use edit instead |
| NotStaged | edit |
value != remote | Update | Stage modification |
| NotStaged | edit |
value = remote | NotStaged | Auto-skip (no change needed) |
| NotStaged | delete |
resource on remote | Delete | Stage deletion |
| NotStaged | delete |
resource NOT on remote | ERROR | Resource doesn't exist |
| NotStaged | tag/untag |
resource NOT on remote | ERROR | Resource doesn't exist |
| Create | add |
- | Create | Update draft value |
| Create | edit |
- | Create | Update draft value |
| Create | delete |
- | NotStaged | Unstage + discard tags |
| Create | reset |
- | NotStaged | Unstage only |
| Create | tag/untag |
- | HasChanges | Tags apply on resource creation |
| Update | edit |
value != remote | Update | Update draft value |
| Update | edit |
value = remote | NotStaged | Auto-unstage (reverted) |
| Update | delete |
- | Delete | Convert to delete |
| Update | reset |
- | NotStaged | Unstage |
| Delete | edit |
- | ERROR | Must reset first |
| Delete | add |
- | ERROR | Cannot add to delete-staged |
| Delete | delete |
- | Delete | No-op |
| Delete | reset |
- | NotStaged | Unstage |
Special Behaviors¶
Auto-Skip¶
When editing a resource that is not staged, if the new value matches the current remote value, the operation is skipped entirely (no staging occurs).
Auto-Unstage¶
When editing a staged resource, if the new value matches the current remote value, the resource is automatically unstaged.
remote value: "foo"
-> edit "bar" -> Update staged (value="bar")
-> edit "foo" -> Unstaged (reverted to remote)
Kept Staged Options¶
When add or edit re-stages an entry that is already staged, a value type (--secure/--type) or --description the command does not give keeps its staged value. Only an option given again replaces it, so an empty description cannot clear a staged one; reset the entry instead. A GUI re-edit never passes these options, so it keeps them. An entry that is unstaged (by reset, or by an edit back to the remote value) loses them.
-> edit /app/config v1 --description "new desc" -> Update staged (description="new desc")
-> edit /app/config v2 -> Update staged (value="v2", description="new desc")
Tag Cascade on Create Delete¶
When a Create-staged resource is deleted (unstaged), any associated tag changes are also discarded. This prevents orphaned tag operations that would fail on apply.
-> add /app/new -> Create staged
-> tag env=prod -> Tags staged
-> delete /app/new -> Both entry and tags unstaged
The same rule applies when stage diff (or the GUI/TUI review) finds that the remote of a staged Update or Delete no longer exists: the entry is auto-unstaged together with its tag changes. A key with only staged tag changes (no entry) whose remote no longer exists has its tag changes discarded the same way.
Resource Existence Checks¶
Before staging operations, suve validates that the resource state on the remote is compatible with the requested action:
| Action | Requirement | Error |
|---|---|---|
add |
Resource must NOT exist on the remote | "cannot add: resource already exists, use edit instead" |
delete |
Resource must exist on the remote (or be staged as Create) | "cannot delete: resource not found" |
tag/untag |
Resource must exist on the remote (or be staged as Create) | "cannot tag/untag: resource not found" |
This prevents common mistakes like:
-> add /app/existing -> ERROR (resource exists, use edit)
-> delete /app/missing -> ERROR (resource not found)
-> tag /app/missing -> ERROR (resource not found)
For staged Create resources, delete unstages instead of erroring, and tag/untag is allowed (tags will be applied when the resource is created).
Tag State Machine¶
States¶
| State | Description |
|---|---|
Empty |
No pending tag changes |
HasChanges |
Has pending tag additions and/or removals |
State Diagram¶
stateDiagram-v2
[*] --> Empty
Empty --> HasChanges: tag (value != remote)
Empty --> Empty: tag (value = remote) [auto-skip]
Empty --> HasChanges: untag (key exists on remote)
Empty --> Empty: untag (key not on remote) [auto-skip]
HasChanges --> HasChanges: tag (add/update)
HasChanges --> HasChanges: untag
HasChanges --> Empty: all changes cleared [auto-unstage]
note right of Empty
Resource NOT on remote
AND NOT staged as Create
-> ERROR for tag/untag
end note
note right of HasChanges
Entry=Delete -> ERROR for tag/untag
end note
Tag Change Tracking¶
Tags are tracked with two sets:
- ToSet: Tags to add or update (key-value pairs)
- ToUnset: Tag keys to remove
Transition Rules¶
| Action | Condition | Result |
|---|---|---|
tag key=value |
key not on remote or value differs | Add to ToSet |
tag key=value |
key on remote with same value | Auto-skip |
tag key=value |
key in ToUnset | Remove from ToUnset, add to ToSet |
untag key |
key exists on remote | Add to ToUnset |
untag key |
key not on remote | Auto-skip |
untag key |
key in ToSet | Remove from ToSet, add to ToUnset if on remote |
Delete-Staged Restriction¶
Tag operations (tag/untag) are blocked when the entry is staged for deletion. This prevents meaningless tag changes on resources that will be deleted.
-> delete /app/config -> Delete staged
-> tag env=prod -> ERROR: cannot modify tags on delete-staged resource
stage import --merge keeps the same rule. When the merged result stages a key for deletion and also has tag changes for it, import drops the tag changes and prints a warning.
Conflict Detection¶
When applying changes, suve checks for conflicts by comparing the BaseModifiedAt timestamp (recorded at staging time) with the current remote LastModified time.
| Operation | Conflict Condition |
|---|---|
| Create | Resource now exists on the remote |
| Update | Remote modified after staging |
| Delete | Remote modified after staging |
The base is recorded when an Update or Delete is first staged and kept while the entry stays staged: a later edit, a delete that turns an Update into a Delete, a repeated delete, and a reset restore (reset NAME#VERSION) all reuse the existing base. An out-of-band write made after the first staging is therefore still detected. Once the entry is unstaged (plain reset, or an edit back to the remote value), the next staging records a fresh base.
The check fails closed. If suve cannot read a staged resource's current state for a reason other than "not found" (for example throttling, a network error, or a policy that denies the read), it cannot prove there is no conflict. Apply then stops before writing anything and reports the error for each resource it could not check. This includes a resource that exists but cannot be read (for example a binary Secrets Manager value). A resource that no longer exists is not a conflict: there is no newer version to lose.
Use --ignore-conflicts to force apply despite conflicts. It also skips the check, so it applies even when the check cannot run.
Timestamp precision. Update/Delete conflicts are detected only when the remote
LastModifiedis strictly after the recordedBaseModifiedAt. On providers whose modified time is second-granular (notably Azure Key Vault), an out-of-band write that lands in the same wall-clock second as the recorded base compares as equal, so it is not flagged as a conflict and can be overwritten on apply. The window is narrow and inherent to the provider's timestamp precision.
Implementation¶
The state machine is implemented in internal/staging/transition/:
state.go: State type definitionsstaged_tags.go: The staged tag changes (StagedTags)action.go: Action type definitionsreducer.go: Pure reducer functions (ReduceEntry,ReduceTag)executor.go: Persists reducer results to the storeload.go: Loads the current entry and tag state from the store (LoadEntryState,LoadStagedTags)
The reducer functions are pure (no side effects) and deterministic, making the staging behavior predictable and testable.