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.