0039. Model connection status as a state machine¶
Date: 2026-09-30 Status: Accepted
Context¶
SystemConnection.status was the backed enum SystemConnectionStatus. Every writer set it
directly: the admin panel actions, the plugin's disconnect and uninstall endpoints,
SystemConnectionService::activate(), and two sync jobs. SystemConnectionObserver::updated()
matched on the new status and ran the side effects that belong to it. A saving() guard refused to
leave Uninstalled. Nothing else defined which status changes were legal.
Checking every path against that arrangement found four problems.
An opened circuit breaker overwrote a connection that had just ended. BulkSyncJob and
FetchBulkPageRangeJob check isHealthy() when they start. They then probe the shop, which can take
seconds. When the circuit breaker opened during that probe, they set Error through a query-builder
update, which no model event sees. If the connection was disconnected in the meantime, Disconnected
became Error. That had a chain of consequences:
disconnected_atstayed set, butprunable()selects onlyDisconnectedrows, so the row was never purged.DisconnectConnectionDataJobfoundErrorwhen it ran and left the business data live.- A reconnect within the grace period found no
Disconnectedsibling. It retired the old row toInactiveand re-imported the shop instead of restoring its data.
The same race overwrote Inactive and Uninstalled.
ADR-0013 had already named this window
in its consequences.
Error was a dead end. The circuit breaker closes after its cooldown, but the status stayed
Error. Webhooks and syncs were discarded until the merchant completed a fresh handshake.
ADR-0009 chose manual recovery, but the
admin panel offered no manual action.
The create form bypassed the side effects. The Filament create form offered every status. The
observer reacted on creation only to Active, so a row created as Uninstalled was never purged.
The Uninstalled guard compared the in-memory original, not the stored row. A stale model
instance could still overwrite Uninstalled.
Decision¶
States on spatie/laravel-model-states¶
SystemConnection.status is a spatie/laravel-model-states state. The abstract base is
App\States\SystemConnection\SystemConnectionState. The six concrete states are Pending, Active,
Inactive, Disconnected, Error and Uninstalled.
Each state's $name alias is the string the column already held (pending, active, and so on).
The column therefore keeps its values, there is no migration, and the API returns the same status
strings as before. The base class carries what the enum carried: the Filament label and colour,
allowsDataFlow(), hasGracePeriod(), and a static options() for select filters. The enum is
deleted.
One transition class per move¶
Every legal move has its own class in App\States\SystemConnection\Transitions. The table is declared
once, in SystemConnectionState::config():
| Transition | From | To | Side effects |
|---|---|---|---|
Activate |
Pending | Active | resets the circuit breaker, generates default sync rules, stamps last_connected_at |
Reconnect |
Disconnected | Active | as Activate, plus RestoreConnectionDataJob with the previous disconnected_at |
Reactivate |
Error | Active | resets the circuit breaker, generates missing default sync rules |
Deactivate |
Pending, Active, Error | Inactive | cancels in-progress batches, revokes all tokens, NotifyConnectionDeactivatedJob, CleanupConnectionDataJob |
Disconnect |
Pending, Active, Error | Disconnected | cancels in-progress batches, DisconnectConnectionDataJob |
RecordError |
Active | Error | cancels in-progress batches |
Uninstall |
every state but Uninstalled | Uninstalled | cancels in-progress batches, revokes all tokens, clears credentials, PurgeConnectionJob |
Reconnect also declares the library's canTransition() precondition: a Disconnected connection may
move back to Active only within its grace period, and the base transition checks it again against the
locked row.
The table reproduces the moves the old code could reach. The only behaviour it changes is the Error
race, which it removes. Four moves are deliberately absent:
- Inactive to Active. The way back stays a fresh handshake, which creates a new row.
- Inactive to Disconnected. Unreachable: an inactive connection's tokens are revoked, so the plugin cannot call the endpoint.
- Error to Pending. No caller needs it.
- Anything out of Uninstalled. Uninstall is final.
Creation is not a transition. A new row defaults to Pending. The tenant bootstrap and
SyncRuleSeeder create Active rows directly, and the observer's created() hook generates their
sync rules.
Our own base transition for the row lock and the re-check¶
The library checks the table only inside transitionTo(). It checks against the state held in memory,
and it takes no lock. A direct assignment or a query-builder write passes unchecked, and a stale
instance passes the check with a state the row no longer has. That is exactly the shape of the Error
race.
Every transition therefore extends the abstract SystemConnectionTransition, whose handle() is
final:
- It refuses a model with unsaved changes by throwing a
LogicException. The reload in the next step would silently drop them. Callers save other fields first and then transition. - It opens a transaction and reloads the row into the same instance with Laravel's
refreshForUpdate(), which selects it withlockForUpdate(). - If the stored state already is the target, it returns without saving and without side effects.
- If the stored state may not move to the target, it calls
refused(). By default that throwsStaleConnectionStatus, aCouldNotPerformTransition. It also callsrefused()when the table resolves the stored move to a different transition class than the one running.SystemConnectionState::canTransitionToVia()answers both questions at once. - Otherwise it runs the transition's
prepare()hook, sets the target state, saves, and runs the transition'ssideEffects()through the container, so each transition declares the services it needs as parameters.
Same-state calls¶
The table deliberately has no ignoreSameState(). A transitionTo() into the state the instance
already holds therefore throws at the library's in-memory check, before the base transition runs.
The callers that can legitimately repeat a move handle that themselves. The disconnect and uninstall endpoints return early when the connection already is in the target state, so both stay idempotent. The base transition's own no-op in step 3 covers only the remaining case: the stored row already moved to the target behind a stale instance.
RecordError keeps a lost race¶
RecordError overrides refused(). When the connection left Active before the error could be
recorded, it logs at info and keeps the stored state. A connection disconnected, deactivated or
uninstalled while a sync was probing the shop is an expected race, not a fault. This removes the
Error race described above.
A guard against status changes outside a transition¶
SystemConnectionObserver::saving() throws App\Exceptions\StatusChangeRequiresTransition when an
existing row's status is dirty and no transition is running. The base transition lifts the guard
only for its own save, through SystemConnection::allowingStatusChange(). That method has to be
public for the transition to reach it, so an architecture test makes sure the base transition is its
only caller. Creation is exempt, so a new row may start in any state.
The guard can only see saves that fire model events. A query-builder update, saveQuietly() and
withoutEvents() all bypass it. The architecture guideline therefore forbids all three for the status
column. The three query-builder status writes that existed were replaced by transitions.
Side effects inside the transaction¶
Every side effect lives in its transition, and the transition's transaction wraps both the status and the side effects. The synchronous ones, such as batch cancellation, token revocation and clearing credentials, roll back together with the status.
Jobs are dispatched with ->afterCommit(). Laravel defers them to the commit of the outermost
transaction, which includes the one SystemConnectionService::activate() wraps around the whole
handshake, and discards them on rollback. No job therefore reads a status that was never committed.
Default sync rule generation is deferred the same way, through DB::afterCommit(). It logs and
swallows its own failures. Run inside the transaction, a MySQL deadlock that rolled back the whole
transaction would be swallowed, and the rest of the handshake would carry on in autocommit.
The observer keeps only what is not a status side effect: the guard, the default configuration, the
disconnected_at invariant, and rule generation in created() and restored(). Its updated()
hook and the match in it are removed.
A manual way out of Error¶
ReactivateConnectionAction moves an Error connection back to Active through Reactivate. It
sits in the admin panel next to Deactivate, Disconnect and Force Uninstall, asks for confirmation, and
is visible only for an Error connection the user may update. It starts no sync itself: the next
scheduled delta sync picks the connection up.
Each of the four status actions is visible only while its own transition can run from the
connection's status, which SystemConnectionState::canTransitionToVia() answers. A click on an action
whose status changed since the page rendered ends with a failure notification and the page shows the
current status, instead of doing nothing or showing an error page. The create form no longer offers a
status, so a connection created in the panel always starts Pending.
A move the API refuses answers with 409 and INVALID_STATUS_TRANSITION, instead of the 500 the old
Uninstalled guard produced.
The alternatives that were not taken¶
A lifecycle service on top of the enum. One service method per move would have kept the enum and needed no dependency. It was rejected for two reasons. A state machine library is a pattern a new reader recognises, and its table is one declaration rather than conditions spread over a service. One class per transition also keeps each move's side effects in one place, with the services it needs injected where it runs.
ignoreSameState(). It would have made same-state calls harmless without early returns in the
callers. With it, though, the library routes a same-state call to its own DefaultTransition, which
bypasses the base transition and its row lock. It also makes canTransitionTo() true for the current
state, so Force Uninstall, whose visibility asks canTransitionTo(Uninstalled::class), would appear on
a row that is already Uninstalled.
Consequences¶
Legal status changes are declared in one place, and a stale instance can no longer overwrite a state
the row has since left. The re-check under the lock asks whether the stored state may move to the
target and also whether the table resolves that move to the transition class that is running. Three
transitions lead to Active, so a stale Error copy would otherwise run Reactivate on a row that is
Disconnected by now. The Error race is gone, and Error has a way out.
The type of $status depends on help from outside larastan. Larastan alone infers mixed, because
the library's castUsing() declares no return type. The ide-helper model mixin resolves it, and the
model also carries an explicit @property SystemConnectionState $status, so the type does not depend
on composer ide-helper having run.
The rule against writing the status column past a transition is enforced by the guard for ordinary
saves only. Query-builder updates, saveQuietly() and withoutEvents() are forbidden by guideline,
and nothing fails automatically when one of them is used.
The library fires its StateChanged event after every handle(), including the same-state no-op of
step 3 and RecordError's log-only refusal. In both cases the status did not change. Nothing listens
to the event today. A future listener has to compare the states itself rather than trust that the
event means a change.
A transition holds the row lock for the length of its synchronous side effects. That is short today:
a few deletes and batch cancellations. A slow side effect added later would hold the lock just as
long. Tests run on SQLite, where lockForUpdate() does nothing, so race tests simulate the race with
a stale model instance rather than with concurrency.
Inactive has no direct way back. Leaving it takes a fresh handshake, which creates a new row, or an
uninstall. Nothing purges an orphaned Inactive row, so it keeps the shop's credentials and its
soft-deleted data indefinitely. How long such rows are retained, and whether a reconnect should reuse
the old row, is a follow-up.
Recovery from Error stays manual. An automatic recovery, such as a scheduled probe of every Error
connection, is a follow-up.
This record supersedes the statement in
ADR-0013 that the trigger stays bound to
the status transition in the observer. The guarantee it served, the same clean-up for every path that
ends a connection, now holds because the transitions are the only way to change the status. It
amends ADR-0009 with the manual
Reactivate action as the recovery from Error.