Skip to main content

Lifecycles

A lifecycle is a value that moves through states over time. It can be a status enum, a set of nullable timestamps, or booleans that together mean a state. These rules apply to the server. ADR 0078 records why we chose them. Some code that is older than ADR 0078 does not obey all the rules yet. Do not copy it.

Add a lifecycle​

  1. Use one enum column for the state. Do not hide a state in a JSON key or in a combination of booleans.
  2. Add a CHECK for the closed set of values, in the same migration as the column.
  3. Add a CHECK for each column that a state implies, such as a reason or a completion time.
  4. Implement Lifecycle<S> and put the legal transitions in one table beside the enum. IntegrationState shows the form.
  5. Write each transition as a compare-and-set on the expected state, as AgentActionRepository.claim does.
  6. Refuse an illegal transition, and record the reason. A database guard for an UPDATE follows Trigger timing.
  7. Decide what a crash, a replay and a retry do to each state. State the answer in a test.

Use a plain enum. Do not add a state machine framework.

Lifecycle is the shared transition contract. LifecycleContractTest discovers every production enum that implements the contract. Each enum has explicit initial states and a terminal set in that test. A lifecycle with no terminal state must declare @ContinuingLifecycle with a nonblank reason. The contract then requires an empty terminal set and an exit from every state. An unmarked lifecycle must have a terminal state. The test checks that terminal states have no exit and that each state is reachable from an initial state.

LifecycleSetterArchTest refuses an entity method named set* that takes a Lifecycle value. Its remaining exceptions name one owner per field. The list includes lifecycle fields that do not yet use the contract. Remove an entry when its field uses the contract and its entity has no lifecycle setter. ADR 0099 § Confirmation owns the release gate for this list. Promote the qualified candidate gives the preflight command.

Choose the rigor​

Rank a lifecycle by the damage of a wrong transition: trust, tenancy, data loss, then stuck work. The levels add up. Levels 1 to 3 follow the rank. Levels 4 and 5 apply only where their condition holds.

LevelAddWhen
1CHECK constraintsEach stored lifecycle
2A transition table and table-driven testsMore than one writer
3Seeded random walks over eventsA wrong transition costs trust, crosses a tenant, or loses data
4Optional: a small TLA+ model, outside CIA concurrent protocol that is hard to reason about: a lease, a permit or a fence
5An invariant query with a metric and an alertA rule across tables, which a CHECK cannot express

Close what you own​

Most defects sit where one lifecycle hands over to another, not inside one. When a parent ends, end its children with the parent's reason. Do this in the same transaction inside one module, and through a durable event across modules (Transactions and side effects). Do not leave a child in a state that only an active parent can move on. Do not report a child as finished when nothing measured it.

Repair old rows first​

A CHECK validates each existing row. Rows from before the 1.0 cutover can break a rule that today's code keeps. Count them on staging and in production first. Then repair them in the same migration.

Review status​

The review glossary defines the statuses. ReviewStatus owns the executable transition table. Review changes its lifecycle through start, requeue, requestCancel and end. These methods refuse illegal moves.

ReviewClosure closes the children, freezes a scan report, records the end and publishes its notifications in the caller's transaction. Every end path uses this closure, including selection refusal and replacement by a newer update.

The database trigger guard_review also refuses a change to a terminal status. It permits metadata updates that keep that status, such as freezing the report.

A requeue keeps the first start time in checks.startedAt. Terminal reviews do not reopen, including when feedback repair resumes a composition run.

Scheduled review batches​

ReviewBatchStatus owns the terminal states. A terminal batch cannot resume. Its transition table is beside the enum in ReviewBatchStatus. A scheduled batch with no selected targets left completes before the driver checks its budget.

BUDGET_EXHAUSTED records a normal end at the run limit. It has a completion time and no pause reason. It does not block the next scheduled batch. An existing scheduled batch paused at its fixed cap takes this transition on the next driver tick. The driver first waits for any scan already in flight to end.

Unknown cost information keeps a pause. It does not prove that the fixed cap is spent. Some costs need operator correction before the run can continue.

A manual dated-work batch has no fixed run cap. It keeps its budget pause until workspace funding permits another review. Its API rejects a fixed scan budget. The review_batch status CHECK includes BUDGET_EXHAUSTED.

Account status​

Account.Status owns the transition table of an account. Account changes its status only through unclaimed, claim, block, unblock, startDeletion and markDeleted. These methods refuse an illegal move.

A repeated purge finds the account deleted and leaves it so. The erasure in PersonDataService starts a deletion with SQL, because it locks the account row itself. That statement moves the account only to DELETING and raises its version, so a stale entity save fails instead of writing back an earlier status. The database trigger guard_account also refuses any move from DELETING other than to DELETED, and any change of a deleted account's status.

Workspace status​

Workspace.WorkspaceStatus owns the transition table: a workspace is active or suspended, and a purge is final. Workspace changes its status through suspend, resume and markPurged. These methods refuse a change of a purged workspace. Only WorkspaceLifecycleService.purgeWorkspace calls markPurged, after every purge contributor erased the workspace's data.

Workspace.create is the one way to build a workspace. WorkspaceCreationArchTest refuses a call of the constructor outside Workspace.

The database trigger guard_workspace also refuses a change of a purged workspace's status.

Person data requests​

PersonDataRequest.State owns the transition table of an erasure request. PersonDataRequest changes its state through startErasure, fail and complete. These methods refuse an illegal move. The SQL of PersonDataService.expirePreviews moves only a PREVIEW to EXPIRED.

After a preview passes its expires_at time, the next expirePreviews run moves it to EXPIRED. expirePreviews removes its scope and selections, so nobody can confirm it. A temporary store failure keeps the request in ERASING with the failure code STORE_ERASURE_RETRY_PENDING, and the next run retries it. complete removes the identifying scope and the selections, so the receipt keeps only counts and times.

The database trigger guard_person_data_request also refuses a change to a completed request's state.

Provider connections​

IntegrationState names each legal edge, including RECONNECT. Connection.transitionTo checks the edge and reconnect authority before it changes state, reason and lifecycle generation together. A same-state call changes none of them. A refused transition changes none of them.

A reconnect reuses the connection row and its audit history. It advances the lifecycle generation so that work from an earlier connection lifetime cannot apply to it. Thus this lifecycle has no terminal state; it declares the reason with @ContinuingLifecycle. INITIATE authorizes reconnect for each provider after its strategy verified fresh credentials. OAUTH_COMPLETE authorizes reconnect only for GitHub and Slack, after their verified OAuth flow. GitLab and Outline do not use that OAuth authority. These event names are internal authority from the connection use case, not caller-supplied state changes.

ConnectionService first coordinates provider identities, then holds the scoped connection lifecycle lock and reads the current row again. It skips a recorded event and refuses an illegal transition before local erasure, cancellation or state writes. The audit identity includes the connection, event type and correlation ID, including a null correlation ID. The unique database index remains the last duplicate fence. Provider resource removal and adoption owns the resource, credential and generation fences.

No new stored state is required for reconnect. The four stored values stay unchanged, so old and new servers read the same state set during an upgrade.