@@ -738,25 +738,34 @@ func ElevateDeploymentTiersByTeam(ctx context.Context, db *sql.DB, teamID uuid.U
738738// (1..8760) BEFORE invoking this — the model trusts its input. Resets
739739// reminders_sent so a freshly-extended deploy gets the full 6-email
740740// warning cycle again instead of skipping reminders that fired earlier.
741- func SetDeploymentTTL (ctx context.Context , db * sql.DB , id uuid.UUID , hours int ) error {
741+ func SetDeploymentTTL (ctx context.Context , db * sql.DB , id uuid.UUID , hours int ) ( bool , error ) {
742742 expiresAt := time .Now ().UTC ().Add (time .Duration (hours ) * time .Hour )
743- // `ttl_policy != 'permanent'` is defense-in-depth (the handler already
744- // rejects a permanent deploy with 409): if a concurrent MakePermanent races
745- // this UPDATE, the WHERE clause prevents silently un-permanenting the deploy
746- // — the row simply isn't touched (bug-bash #6).
747- _ , err := db .ExecContext (ctx , `
743+ // The WHERE clause is the AUTHORITATIVE guard, closing the handler's
744+ // check-then-act TOCTOU (bug-bash #6, hardened post-review):
745+ // - `ttl_policy != 'permanent'` — a concurrent MakePermanent must not be
746+ // silently un-permanented.
747+ // - `status NOT IN <lifecycleTerminalStatusesSQL>` — a deploy reaped to
748+ // expired/deleted/stopped between the handler's status read and this
749+ // UPDATE must NOT get a re-armed expires_at + reminders_sent=0 reset
750+ // (which would re-enter the reminder-email cycle for a gone deploy).
751+ // Returns whether a row was actually updated so the handler can surface a
752+ // 409 when the deploy changed underneath it rather than reporting a phantom
753+ // success for a write that never happened.
754+ res , err := db .ExecContext (ctx , `
748755 UPDATE deployments
749756 SET expires_at = $1,
750757 ttl_policy = 'custom',
751758 reminders_sent = 0,
752759 last_reminder_at = NULL,
753760 updated_at = now()
754761 WHERE id = $2 AND ttl_policy != 'permanent'
762+ AND status NOT IN ` + lifecycleTerminalStatusesSQL + `
755763 ` , expiresAt , id )
756764 if err != nil {
757- return fmt .Errorf ("models.SetDeploymentTTL: %w" , err )
765+ return false , fmt .Errorf ("models.SetDeploymentTTL: %w" , err )
758766 }
759- return nil
767+ n , _ := res .RowsAffected ()
768+ return n > 0 , nil
760769}
761770
762771// GetDeploymentsExpiringSoon returns deployments whose expires_at falls
@@ -955,6 +964,17 @@ const activeDeploymentStatusesSQL = `('building', 'deploying', 'healthy')`
955964// "user-visible deployments" row set — see deploymentVisibleClause.
956965const terminalDeploymentStatusesSQL = `('deleted', 'expired')`
957966
967+ // lifecycleTerminalStatusesSQL is the IN-list for "this deploy has reached an
968+ // end state — reject lifecycle MUTATIONS on it." It deliberately DIFFERS from
969+ // terminalDeploymentStatusesSQL: that one answers "hide from the user's list /
970+ // usage count" (a 'stopped' deploy is still VISIBLE — paused, not gone — so
971+ // it's excluded there). This one answers "may we re-arm a TTL on it", where
972+ // 'stopped' IS terminal — matching IsDeploymentTerminal. The two lists encode
973+ // different questions; keeping them separate is intentional, not drift. Used
974+ // by SetDeploymentTTL's UPDATE guard so a deploy reaped between the handler's
975+ // status read and the UPDATE cannot get a re-armed TTL + reminder-cycle reset.
976+ const lifecycleTerminalStatusesSQL = `('deleted', 'expired', 'stopped')`
977+
958978// deploymentVisibleClause is the shared WHERE predicate for "deployments the
959979// user sees" — i.e. every non-terminal row. GET /api/v1/deployments (the list)
960980// and GET /api/v1/billing/usage's deployment count MUST use this same clause
0 commit comments