Skip to content

fix(deploy): SetTTL terminal/permanent guard authoritative in SQL — close TOCTOU#237

Merged
mastermanas805 merged 4 commits into
masterfrom
fix/setttl-terminal-sql-guard-2026-06-04
Jun 3, 2026
Merged

fix(deploy): SetTTL terminal/permanent guard authoritative in SQL — close TOCTOU#237
mastermanas805 merged 4 commits into
masterfrom
fix/setttl-terminal-sql-guard-2026-06-04

Conversation

@mastermanas805

Copy link
Copy Markdown
Member

What (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'. The review panel surfaced:

  1. TOCTOU — a deploy reaped to expired/deleted/stopped (or made permanent) between the handler's status read and the UPDATE still got expires_at re-armed + reminders_sent reset 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.
  2. The terminal SQL guard was missing entirely (only permanent was guarded).

Fix

Make the status-guarded UPDATE the authoritative guard:

WHERE id=$2 AND ttl_policy != 'permanent' AND status NOT IN ('deleted','expired','stopped')

SetDeploymentTTL returns whether a row was actually updated. The handler drops the stale pre-read; on !applied it re-reads (now authoritative) only to choose 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 = "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

Symptom:        TTL re-armed on a concurrently-reaped/permanent deploy; phantom 200 on lost race
Sites:          SetDeploymentTTL (model) + SetTTL handler
Coverage test:  model SetDeploymentTTL 100% (+0-rows→applied=false sqlmock case);
                existing TestSetTTL_{Permanent,Terminal}Rejected now deterministically cover the
                re-read !applied sub-branches (permanent→already_permanent, terminal→invalid_state)
Live verified:  go test vs real Postgres+Redis; all changed lines covered (count≥1)

Signature change threaded through 3 callers.

🤖 Generated with Claude Code

…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>
@mastermanas805 mastermanas805 enabled auto-merge (squash) June 3, 2026 20:22
@mastermanas805 mastermanas805 merged commit 613485f into master Jun 3, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant