Repository navigation
api: refuse to replay the create of a revoked or ended session - #811
Conversation
Replaying a data-plane session's create with the same idempotency key refreshed the session's attach token without checking its status. A replay of a revoked, closed or failed session returned 201 with a token that could not be used. The replay's token refresh now matches only an active session in a live state. When it matches nothing and the session still exists, the replay returns the existing conflict code (409, not retryable) with the detail reason=session_revoked and mints no token. The refresh's row lock orders it against a concurrent revoke. Token semantics are otherwise unchanged.
The replay's token refresh now also skips a session in the closing state, which can no longer be attached to. A replay of a session past its expires_at found the session through the idempotency record but not through the live-session lookup, and surfaced as an internal error. It now returns the existing conflict code (409, not retryable) with the detail reason=session_expired. A missing session still takes the previous path.
ac-pplx
left a comment
There was a problem hiding this comment.
Thanks, the guard is in the right place and nothing here regresses main. The items below are small. Most of them concern the public contract this PR introduces (the new 409 and its reason values), which is harder to change after release.
Before merge
-
The 409 holds for about an hour after a revoke, and then the replay returns a retryable 500 again.
crates/libs/api/src/public_sessions.rs:115-132- Cause. The new answer depends on the session row still existing:
- The data-plane session retention job deletes rows with
state IN ('closed', 'revoked', 'failed')onceupdated_atis older than its 1-hour grace (crates/libs/cluster/controller/server/src/data_plane_session_retention.rs:9,70-77). - The idempotency record lives 24 hours (
public_sessions.rs:762) and the create operation 7 days (public_sessions.rs:709). - After the sweep, the operation still yields the session id, the refresh matches nothing,
fetch_session_txreturnsNone, andsession_expired_txreturnsfalse. The result isMissingIdempotencyTarget, which falls into theother =>arm, logs aterror, and answersinternal(): a 500 withretryable: true(crates/libs/api/src/routes/public/error.rs:75,397-400).
- The data-plane session retention job deletes rows with
- Failure scenario. Create a command with key K,
DELETEits session, wait a little over an hour, then replay with K. The client gets a retryable 500 instead of the documented non-retryable 409, and keeps retrying. That covers most of the 24-hour window that the new sentence indocs/api/index.md:39describes. - Suggested fix. Return the same 409 when the create operation exists but its session row does not. For example, make the expiry probe a present / expired / absent lookup and map "absent" to the 409. Keep
MissingIdempotencyTargetfor an operation that has no target session, which is genuine inconsistency. Add a store test that deletes the row after a revoke and expects the 409. Also update the comment atpublic_sessions.rs:129-130("still exists after a revoke or close"). - Why an absent row is safe to read as "ended". By this point, the only realistic way to get here is that retention swept an ended session:
- The create writes the session, the operation and the key record in one transaction under the key's advisory lock, so a replay never sees a partial create.
- The route has already rejected a missing sandbox, and the fingerprint pins the sandbox id.
- An expired session is swept only an hour after it expires, by which time its key has expired too.
- Alternatives I would avoid.
- Lengthening the retention grace to cover the key's 24 hours keeps ended rows much longer, and makes this contract depend on a constant in another component staying above the API's key lifetime.
- Treating the swept row as a fresh create would run the command a second time.
- Cause. The new answer depends on the session row still existing:
-
The generated OpenAPI contract still describes the old behaviour.
crates/libs/api/src/routes/public_docs/error_examples.rs:37- What is stale:
- The examples table lists only
idempotency_conflictfor createCommand's 409, andcrates/libs/api/src/routes/public_docs/tests.rs:1174pins that list. - createCommand's 409 is described as "Idempotency conflict" (
crates/libs/api/src/routes/public_docs/path_contract.rs:122), and so are the terminal, process, file-watch, port and protocol session creates (crates/libs/api/src/routes/public_docs.rs:136,146,156,166,176). - The spec intro still says "Within 24 hours, this returns the original result" (
public_docs.rs:82). - The
PublicErrorEnvelope.detailsdoc lists only thenot_founddetail (crates/libs/public/api/src/lib.rs:2008-2012).
- The examples table lists only
- Failure scenario. A client written against the published spec reads a 409 from createCommand as "you changed the body", when the body is identical and the session is gone.
- Suggested fix. Add
Conflictto the createCommand 409 examples and update the test. Widen the six 409 descriptions. Qualify the "original result" sentence. Mention thereasondetail in thedetailsdoc. - Related wording in
docs/api/index.md:39. "A command whose session was revoked or has ended" reads as "the command finished". But nothing writesclosing,closedorfailedtoday: only the createINSERT(pending) and revoke (revoked) writestate, and a command finishing changes neitherstatenorrevocation_status. So the sentence covers only revoked sessions in practice. Saying "revoked" there is accurate until something writes an ended state.
- What is stale:
-
No test fails if
AND revocation_status = 'active'is removed.crates/libs/api/src/public_sessions/tests.rs:1182-1240- Why. The guard has two predicates, and the tests only ever vary the second. The revoked test goes through
revoke_session, which sets bothrevocation_statusandstate(public_sessions.rs:338,341), and the ended-state test sets onlystate. So thestatepredicate alone satisfies all three store tests, and deleting therevocation_statuspredicate (public_sessions.rs:619) is not caught. The schema also allowsrevocation_status = 'revoking'(db/postgres/20261002070625_baseline.up.sql:777), which no test uses. - Failure scenario. A later cleanup drops the revocation predicate as redundant with the state list, and the guard quietly narrows to the state check for any future path that changes only
revocation_status, without any test failing. - Suggested fix. Add cases with
state = 'pending'andrevocation_statusset torevokingand torevoked, asserting the same outcome as the existing cases.
- Why. The guard has two predicates, and the tests only ever vary the second. The revoked test goes through
-
Settle the
reasonvalues before they ship (your call).crates/libs/public/api/src/lib.rs:2024-2034session_revokedis broader than its name. It is also the answer forclosing,closedandfailedsessions (public_sessions.rs:131-132), and it would cover the swept rows from item 1. Only revocation reaches it today, but the value is permanent once published.session_expiredis reachable only under clock skew:- The session's
expires_atis the API host'snow_utc()plus 24 hours, read after the transaction began (public_sessions.rs:146-149). - The record's
expires_atis the databaseNOW(), the transaction start, plus 24 hours (public_sessions.rs:762). - Nothing else writes
data_plane_sessions.expires_at. - So with aligned clocks the key expires first, and the replay creates a new session (pinned by
tests.rs:442-469). - The description's note that an API-level test "would need the session's 24-hour TTL to elapse" does not hold for the same reason.
- The session's
- Suggested fix. Either use one neutral value (for example
session_ended) for revoked, ended, swept and expired sessions, and dropCONFLICT_REASON_SESSION_EXPIREDbefore it ships. Or keep both values, but say in a comment that the expired branch guards against clock skew, so it is not later removed as dead code. Either choice changes the follow-up about documentingsession_expired.
Follow-ups
- The state list is a separate "ended" rule that nothing else enforces.
crates/libs/api/src/public_sessions.rs:620- Why. The list
closing,closed,revoked,failedis a new definition of an ended session. Retention uses a different list that omitsclosing(data_plane_session_retention.rs:76),SessionStatehas no predicate for it (crates/libs/public/api/src/lib.rs:737-746), and the other readers of these rows decide onrevocation_statusrather thanstate. Nothing writes these states today, so the description's statement aboutclosingsessions describes intent rather than current behaviour. - Failure scenario. Not reachable today, because nothing writes these states. Once something writes these states, the replay path, the data plane and retention would each apply a different rule to the same session.
- Suggested fix. Either add an ended (or attachable) predicate to
SessionStateand bind it in both SQL sites, or rely onrevocation_statusalone until a state writer exists.
- Why. The list
Nits
- Duplicated detail-building.
crates/libs/api/src/routes/public/error.rs:376-395. The two new arms repeat the same conflict-plus-detail block, so a third reason would copy it again. Aconflict_with_reasonconstructor, like the existingpath_not_found(error.rs:177-184), would keep it in one place. - The refresh
UPDATEhas no expiry predicate.crates/libs/api/src/public_sessions.rs:612-621. It lacks the(expires_at IS NULL OR expires_at > NOW())predicate that revoke, process binding and reconnect apply (public_sessions.rs:349,crates/libs/internal/api/server/src/data_plane_sessions/process_binding.rs:42,121), so an expired live row is updated and then rolled back when the error drops the transaction. That is correct today, and the expired test pins the rollback. Adding the predicate would make the guard self-contained, so it does not depend on the error path never committing.
… reason Data-plane session retention deletes a revoked session's row an hour after the revoke, while the create's idempotency key lives 24 hours. A replay after the sweep found the operation but no session row and answered MissingIdempotencyTarget, a retryable 500, so a client kept retrying a create that can never succeed. The replay's token refresh now matches only an active, unexpired row, and a refresh that matches nothing is SessionEnded: revoked, expired and swept sessions all get the same non-retryable 409 conflict with the detail reason=session_ended. A missing row is safe to read as ended because the create writes the session, operation and key record in one transaction under the key's lock. An expired row can only outlive its key when the API host's clock runs behind the database's. The guard reads revocation_status alone, as the data plane does, rather than a separate list of ended states that nothing writes yet. The expiry predicate makes the refresh self-contained instead of relying on the error path rolling back its update. session_revoked and session_expired are replaced by session_ended before either ships. conflict_with_reason builds the detail in one place.
The generated OpenAPI still described a session or command create's 409 as only an idempotency conflict, and said a replay within 24 hours returns the original result. A client written against it would read the new conflict as a changed body. createCommand's 409 examples now include conflict, the six create 409 descriptions name the revoked-session replay, and the intro qualifies "original result". docs/api/index.md says "revoked", since nothing writes an ended state yet.
The replay's error rolls back its transaction, and expect_err already shows the create did not run again, so the count could not fail on its own.
ac-pplx
left a comment
There was a problem hiding this comment.
The problem. A session or command create takes an Idempotency-Key, and a same-key retry within 24 hours returns the original session with a newly rotated attach token. On main, that retry does not check the session first. After the session is revoked, the retry reports success with a token that cannot be used. Once retention deletes the revoked row, about an hour later, the retry fails with a retryable 500.
Example.
POST /v1/sandboxes/{id}/commands Idempotency-Key: K -> 201, session S
DELETE /v1/sandboxes/{id}/sessions/S
POST ... (same body, key K)
main: 201, state: revoked, unusable token; after retention: 500, retryable: true
this PR: 409 conflict, retryable: false, details [{"key":"reason","value":"session_ended"}], before and after retention
Verdict: approved. Every item from the last review is fixed, and nothing here blocks. Please refresh the PR description before merging.
Status of the previous items
- A swept row gave a retryable 500: fixed. A refresh that matches no row, including an absent one, is
SessionEnded(crates/libs/api/src/public_sessions.rs:118-120), andtests.rs:1288pins it. - The OpenAPI contract was stale: fixed. This covers the examples and their test, the six 409 descriptions, the intro, the
detailsdoc anddocs/api/index.md:39. - The revocation predicate was not pinned: fixed (
crates/libs/api/src/public_sessions/tests.rs:1223). - The reason values: fixed, with one
session_ended(crates/libs/public/api/src/lib.rs:2032). - The separate state list: fixed. The guard is
revocation_statusplus expiry (public_sessions.rs:606-610). - Both nits: fixed (
crates/libs/api/src/routes/public/error.rs:187,public_sessions.rs:610).
Before merge
- The PR description still describes the previous design.
- What is stale. It names
session_revokedandsession_expired, theclosing/closed/revoked/failedstate list, andSessionRevoked/SessionExpired. It says "any other missing target still returnsMissingIdempotencyTarget". It lists test names that no longer exist and reports validation atd5fefdc. It also has a follow-up to documentsession_expired, and it does not mention the swept-row test or the OpenAPI changes. - Why it matters. A reader of the merged PR would expect
session_revoked, and a 500 after the sweep. The rule is inCONTRIBUTING.md:111-113and:119-120. - Suggested fix. Describe one
session_endedreason, from a refresh guarded byrevocation_status = 'active'and expiry, where zero rows (including an absent row) is the 409. List the current tests and validation head, and drop thesession_expiredfollow-up. Restate the Kind evidence with the new value, or label it as from the earlier commit. - Optional. The squash commit is built from the commit messages, and the first two still name the removed values, so you may want to reword or squash them.
- What is stale. It names
Nits
- The comment's explanation of a missing row is incomplete.
crates/libs/api/src/public_sessions.rs:113-117says a missing row "was swept by retention after it ended". A sandbox deleted between the route's owner check and this transaction also removes it, through the sessions' cascade (db/postgres/20261002070625_baseline.up.sql:785). The 409 is still the right answer there. Mentioning both causes would keep the comment accurate.
|
thanks for the review. all addressed in 92b523a, 0295930, 7769d52:
follow-up: dropped the separate state list, so the guard relies on nits: added |
ac-pplx
left a comment
There was a problem hiding this comment.
The problem. A session or command create takes an Idempotency-Key, and a same-key retry within 24 hours returns the original session with a newly rotated attach token. On main, the retry does not check the session first. After a revoke, it reports success with a token that cannot be used. After retention deletes the revoked row, about an hour later, it fails with a retryable 500.
Example.
POST /v1/sandboxes/{id}/commands Idempotency-Key: K -> 201, session S
DELETE /v1/sandboxes/{id}/sessions/S
POST ... (same body, key K)
main: 201, state: revoked, unusable token; after retention: 500, retryable: true
this PR: 409 conflict, retryable: false, reason session_ended, before and after retention
Verdict: approved. The rewritten description and the test cleanup look good. Dropping the row count is fine, because SessionEnded is returned before any insert and an error rolls back the transaction. What is left is description polish and one comment.
Status of the previous items
- The stale PR description: fixed.
- The comment naming only retention (
crates/libs/api/src/public_sessions.rs:113-117): not changed yet. A sandbox deleted after the route's owner check also removes the row, through the cascade atdb/postgres/20261002070625_baseline.up.sql:785. The 409 is still right; mentioning both causes would keep the comment accurate. - Optional rewording of the first two commit messages: not done, which is fine. They still name
session_revokedandsession_expired.
Before merge (small)
- Link an issue for the follow-up, and compare the Kind evidence against
main.- The follow-up. The
SessionStateended or attachable predicate, including retention's missingclosing(crates/libs/cluster/controller/server/src/data_plane_session_retention.rs:76), is recorded only in "Not covered".CONTRIBUTING.md:116-117asks for an issue so it is tracked after merge. - The Kind "before" bullet. It compares against
d5fefdcand shows asession_revoked409 that never ships.maintakes the same path after the sweep (zero-row refresh, no row,MissingIdempotencyTarget, a retryable 500), and right after a revoke it returns 201 with an unusable token. Statingmain's behaviour keeps the description to what merging does (CONTRIBUTING.md:111-115).
- The follow-up. The
Nits
- Two sentences in the description misstate expiry.
- "An expired row can only outlive its key when the API host's clock runs behind the database's" has it backwards (the same wording is in the
92b523amessage). It is the key that has to outlive the session's expiry for a replay to see an expired row, and that needs the host clock to run behind. The code comment atpublic_sessions.rs:116-117has it right, and could be reused. - "Revocation is the only path that ends a session today" leaves out expiry. "The only ended state anything writes today" would be accurate.
- "An expired row can only outlive its key when the API host's clock runs behind the database's" has it backwards (the same wording is in the
Summary
Replaying a native session create (same method, path, body and
Idempotency-Keywithin the key's 24 hours) after the session was revoked now returns the existingconflictcode instead of201with a token for a dead session. The response is409withretryable: falseand the detail{"key": "reason", "value": "session_ended"}.At the moment the replay path refreshes the session's attach token without checking its status. After
DELETE /v1/sandboxes/{id}/sessions/{session_id}, a replay of the original command create returns201withstate: revokedand a fresh token that cannot be used.The refusal also has to outlive the session row. Data-plane session retention deletes a revoked row one hour after the revoke, but the key lives 24 hours and the create operation 7 days. Without handling that, a replay after the sweep finds the operation but no row and answers a retryable
500, so a client keeps retrying for most of the key's lifetime.What changes:
refresh_attach_token_tx) only matches a row withrevocation_status = 'active'andexpires_atin the future. It readsrevocation_statusrather thanstate, like the data plane's readers of these rows: nothing writesclosing,closedorfailedyet, so a separate list of ended states would be a rule nothing else applies.SessionEnded. This covers a revoked or revoking session, an expired one, and one whose row retention has deleted. A missing row is safe to read as ended: the create writes the session, the operation and the key record in one transaction under the key's advisory lock, so a replay never sees a partial create. An expired row can only outlive its key when the API host's clock runs behind the database's, because the session's expiry comes from the host and the key's from the database. Treating the missing row as a fresh create would run the command a second time.MissingIdempotencyTarget.SessionEndedtoconflictthrough a newconflict_with_reasonconstructor, with two new constants inpplx-space-public-api:CONFLICT_REASON_DETAIL_KEY(reason) andCONFLICT_REASON_SESSION_ENDED(session_ended). No new error code is added.conflictas well asidempotency_conflict. The 409 descriptions of createCommand and the terminal, process, file-watch, port and protocol session creates mention the replay of a revoked session. The intro qualifies "returns the original result", andPublicErrorEnvelope.detailsdocuments the reason detail.docs/api/index.mdgains one sentence on this 409 in "Retry safely". It says "revoked" because revocation is the only path that ends a session today.Nothing else changes. Creating a session, a replay of a live session (including rotating its token), validation and revocation all behave as before. This is the status guard from the larger proposal #809, split out so it does not depend on the credential-overlap decision deferred in the review of #79 and the review of #91.
native_contract.command_replay_credentialsstaysignored.Validation
At this head (
7769d52, onmainat4324915):cargo fmt --all --check: pass.cargo clippy -p pplx-space-api -p pplx-space-public-api --all-features --all-targets -- -D warnings: pass.cargo nextest run --config 'profile.test.package.pplx-space-api.opt-level=0' --all-features -p pplx-space-api -p pplx-space-public-api -E 'not group(privileged)'with nextest's Postgres and Redis (SPACE_CI_REQUIRE_TEST_DEPS=1, so a missing dependency fails instead of skipping): 1952 passed, 1 skipped, no dependency skips.Store tests, against a real Postgres:
replay_of_a_revoked_session_is_refused_without_a_token: revoke, then replay, givesSessionEnded, and the stored token stays cleared.replay_is_refused_once_revocation_starts_while_still_pending: withstateleftpending,revocation_statusset torevokingand then torevokedare each refused, and the stored token is unchanged.replay_of_an_expired_session_is_refused: withexpires_atin the past, the replay givesSessionEnded, and the stored token is unchanged.replay_of_a_revoked_session_is_refused_after_its_row_is_swept: revoke, delete the row as retention does, then replay, givesSessionEnded.HTTP and contract tests:
ended_session_replay_is_a_non_retryable_conflict_with_a_reason: the envelope is 409conflict,retryable: false, with thesession_endeddetail.supported_error_examples_match_envelopes_status_and_retryabilitynow expectsconflictandidempotency_conflictamong createCommand's 409 examples.Mutation check, one mutation at a time, restoring the file before the next. Each mutation made the named tests fail:
revocation_statuspredicate removed: the revoked test and the pending-state test.revocation_status <> 'revoked': the pending-state test, on itsrevokingcase.MissingIdempotencyTarget).MissingIdempotencyTargetagain: the swept and expired tests.SessionEndedmapped toidempotency_conflict: the HTTP test.idempotency_conflictonly: the examples test.Kind, on a local cluster set up from
docs/installation.md(install-fc-vendor.sh,bootstrap.sh), driven only through the public API. I created two commands with their own keys, revoked both sessions, and waited for the controller's retention job to delete the rows (GETon each session went from200 revokedto404):d5fefdc): a replay right after the revoke gave409withsession_revoked. After the sweep, the replay gave500{"code":"internal","retryable":true}, and the API loggedsession idempotency operation has no target session.7769d52), deployed over the same database: both swept replays gave409{"code":"conflict","retryable":false,"details":[{"key":"reason","value":"session_ended"}]}. A fresh create, revoke and replay gave the same409. A live create and its replay both gave201with the same session id./openapi-m1.jsonlistsconflictandidempotency_conflictunder createCommand's 409, and the intro carries the qualified sentence.Typed smoke on that cluster at this head:
bash scripts/tests/smoke-kind-typed.sh nativeran 26 scenarios, all passed, includingnative.m1_generated_sdk_conformance.native_contract.command_replay_credentialswas reportedIGNORED, as onmain.Not validated: an expired session through the API, because it needs the API host's clock to run behind the database's; the store test covers it.
docs/book/test.pywas not run, because it needs a built guide. The docs change is one sentence in an existing paragraph.Severity and risk
Severity: low. A replay currently returns
201and a token that cannot attach, so a client wastes a round trip and sees a confusing failure later. After retention sweeps the row, it returns a retryable500instead. No access is granted: in an earlier Kind run onmain, attaching with the returned token failed.Risk: a client that replays a revoked session's create and treats any
201as success now gets a409. A replay after the sweep or after expiry previously returned a500, so no client could have relied on it succeeding. The guard runs in the existing replayUPDATE, and the replay of a live session is unchanged. Roll back by reverting this PR. No migration is involved.Not covered
Follow-ups:
closing,closedorfailed, giveSessionStatean ended (or attachable) predicate and apply it here, in the data plane and in retention, which today omitsclosing. Until then, this guard followsrevocation_status.AI assistance: Claude Code drafted the code, tests, and this description; the author reviewed them.