fix(deploy): SetTTL terminal/permanent guard authoritative in SQL — close TOCTOU#237
Merged
Merged
Conversation
…lose TOCTOU Design-review follow-up to #228. SetTTL guarded permanent + terminal deploys via a PRE-READ of the looked-up row, then called SetDeploymentTTL whose UPDATE only guarded `ttl_policy != 'permanent'`. Two problems the panel surfaced: 1. TOCTOU: a deploy reaped to expired/deleted/stopped (or made permanent) between the handler's status read and the UPDATE would still get expires_at re-armed + reminders_sent reset to 0 — re-entering the 6-email reminder cycle for a deploy the user thinks is gone. A lost race also returned 200 "TTL set" for a write that never happened. 2. The terminal SQL guard was missing entirely (only permanent was guarded). Fix: make the status-guarded UPDATE the AUTHORITATIVE guard. SetDeploymentTTL now `WHERE id=$2 AND ttl_policy != 'permanent' AND status NOT IN ('deleted','expired','stopped')` and returns whether a row was actually updated. The handler drops the stale pre-read; on !applied it re-reads (now authoritative, not racy) ONLY to pick the precise code — already_permanent vs invalid_state — so a concurrent change yields an honest 409, never a phantom 200. Added lifecycleTerminalStatusesSQL (deleted/expired/stopped) as a NAMED const, deliberately distinct from terminalDeploymentStatusesSQL (deleted/expired): the former is "reject lifecycle mutations" (stopped is terminal), the latter is "hide from the user's list" (stopped is still visible). Documented why they differ — the panel's "unify them" call was wrong; they answer different questions. Tests: model SetDeploymentTTL now 100% (added the 0-rows→applied=false sqlmock case); the existing TestSetTTL_{Permanent,Terminal}Rejected now deterministically cover the handler's re-read !applied sub-branches (permanent→already_permanent, terminal→invalid_state). Signature change threaded through 3 callers. Verified vs real Postgres+Redis; all changed lines covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What (design-review follow-up to #228)
SetTTLguarded permanent + terminal deploys via a pre-read of the looked-up row, then calledSetDeploymentTTLwhose UPDATE only guardedttl_policy != 'permanent'. The review panel surfaced:expires_atre-armed +reminders_sentreset to 0, re-entering the reminder-email cycle for a deploy the user thinks is gone. A lost race returned 200 "TTL set" for a write that never happened.Fix
Make the status-guarded UPDATE the authoritative guard:
SetDeploymentTTLreturns whether a row was actually updated. The handler drops the stale pre-read; on!appliedit re-reads (now authoritative) only to choose the precise code —already_permanentvsinvalid_state— so a concurrent change yields an honest 409, never a phantom 200.Added
lifecycleTerminalStatusesSQL(deleted/expired/stopped) as a named const, deliberately distinct fromterminalDeploymentStatusesSQL(deleted/expired): the former = "reject lifecycle mutations" (stopped is terminal); the latter = "hide from list" (stopped is still visible). The panel's "unify them" call was wrong — documented why they differ.Coverage
Signature change threaded through 3 callers.
🤖 Generated with Claude Code