Skip to content

0013. Model disconnect and uninstall as distinct states

Date: 2026-08-05 Status: Accepted

Context

Two behaviours end a SystemConnection with opposite guarantees. Disconnecting keeps its data and credentials for a 30-day grace period, stops serving that data, and can be reversed by reconnecting. Uninstalling deletes everything at once and is final.

Before this decision both would have landed on SystemConnectionStatus::Inactive. SystemConnectionObserver::updated() reacted to the status transition alone and could not tell the two apart, so neither retention nor deletion could be attached to either.

Inactive also already carried a third meaning that neither of the two describes: SystemConnectionService::activate() retires sibling connections of the same tenant, system and external user so the unique index survives a reconnect. That retirement promises nothing about the retired connection's data.

An earlier draft of this record (never merged, MR 95) decided that reactivation is "start fresh, not pause": clean-up ran immediately on deactivation and a reactivated connection rebuilt its data from a fresh sync. The grace period requirement supersedes that, so the draft's central decision is not carried over. Its reasoning for hanging the trigger on the status transition is, and is restated below.

Decision

Two new enum cases, Disconnected and Uninstalled, rather than Inactive plus a reason column. One state per meaning: every query reads a single column, the Filament badge and the status filter need no derived presentation, and an ended connection without a stated reason is impossible. match over the status is checked for exhaustiveness by PHPStan at level 10, so a seventh state cannot slip through a branch silently. The status column is a string, not a database enum, so new values need no schema change.

Inactive keeps its narrower meaning: sibling retirement on reconnect, and connections deactivated before this decision. It promises neither retention nor deletion.

Existing Inactive rows are left untouched. They were deactivated under the old meaning, where nothing was retained and nothing was deleted, which is exactly what Inactive still means. Moving them to Disconnected would have scheduled their data for deletion under a promise nobody made when they were deactivated: with a fresh timestamp they would get a grace period retroactively, and with their historical updated_at the first expiry run would delete them at once. There is no data migration and no backfill.

disconnected_at is an invariant of the state, not a duty of the caller. The column is set for exactly as long as status is Disconnected, and SystemConnectionObserver::saving() establishes that on every save: it stamps now() when the state is entered without a timestamp, leaves a timestamp the caller supplied, and clears it when the state is left. No path that ends a connection can forget it, and a reconnect clears the deadline as a side effect of changing the status. The deadline itself is derived (SystemConnection::graceDeadline(), config key conflux.disconnected_connection_grace_period_days, default 30 days), never stored, so the window stays configurable per environment.

The trigger stays bound to the status transition in the observer rather than to a controller, action, or endpoint, so the guarantee holds regardless of which path ends the connection: the admin panel, a plugin calling the disconnect endpoint, sibling retirement, or a direct service call. This reasoning is carried over from the MR 95 draft.

The deletion rules that follow. Disconnecting soft-deletes the connection's business data so it stops being served, keeps tokens and credentials for the window, and deactivates the connection's sync rules; nothing is removed permanently. Reconnecting within the window restores exactly the rows whose deleted_at is at or after the disconnect timestamp, which excludes rows an earlier reconcile run legitimately removed. Uninstalling invokes the same deletion mechanism with no window at all and additionally purges the stored credentials, which hold both the shop API access and the webhook signing secret. Once the window closes without a reconnect, a scheduled command invokes that same deletion.

Consequences

Uninstalled is a short-lived state. The deletion mechanism removes the connection row itself, so the state exists only between the uninstall and the completion of an asynchronous deletion that can touch an unbounded number of rows. That is intended: while the row exists, the reason it is ending must be readable.

The disconnected_at invariant holds for model instances, not for mass updates. A query-builder update() fires no model events, so any path that changes the status that way bypasses the maintenance entirely. Three such updates exist today: SystemConnectionService::activate() sets Inactive to retire a sibling connection, and BulkSyncJob and FetchBulkPageRangeJob set Error when the circuit breaker opens. None of them sets Disconnected, so no timestamp is ever missed on the way in. The two Error updates carry no status precondition, though, so a job still in flight when its connection has ended overwrites that end state. For Disconnected it drops the connection to Error with disconnected_at intact, which takes it out of the grace-period bookkeeping without deleting anything. For Uninstalled it is worse once deletion hangs off that arm: the row can be flipped back to Error after the deletion was dispatched, leaving a half-deleted connection that reports nothing unusual. Cancelling in-flight work once a connection stops being active closes both windows, and whatever service ends connections has to work on model instances regardless.

The states carry no behaviour on their own. Retention, restoration and deletion are separate pieces of work, and until they land a connection can reach Disconnected or Uninstalled without anything following from it. isHealthy() is status === Active, so both states already stop traffic through every existing guard.