fix: reject session create on a soft-deleted id - #2471
Conversation
58c56d2 to
ca28f9b
Compare
Creating a session with the id of a soft-deleted one matched the tombstone through ON CONFLICT and updated it with deleted_at still set, so the row was invisible to every read path, including the create path's own reload, which then failed with an internal error on every retry. The caller was told the request failed while a write had happened. UpsertSession now guards its DO UPDATE with deleted_at IS NULL and returns no rows when the write is rejected, the same shape UpsertTask already uses, so a retired id is reported as a conflict and nothing is written. The tombstone and the events and tasks it owns are left exactly as they are. The sandbox A2A transport fails a request whose contextId is a retired id instead of continuing without a session row. Continuing would create a session actor for an id that can never hold a visible row again, which no read path can see and no delete can reach. Other store failures there are still logged and the request proceeds. Fixes kagent-dev#2279 Signed-off-by: QuentinBisson <quentin@giantswarm.io>
ca28f9b to
680518b
Compare
There was a problem hiding this comment.
Pull request overview
Fixes a correctness bug in the Postgres session upsert path where ON CONFLICT (id, user_id) DO UPDATE could match a soft-deleted (“tombstoned”) session row, causing an invisible write and a subsequent 500 on reload. The change makes soft-deleted session IDs reject writes atomically at the SQL layer and maps that condition through the service and transports to stable, user-meaningful error codes.
Changes:
- Harden
UpsertSessionto not update soft-deleted rows (WHERE session.deleted_at IS NULL) and return no rows when rejected (mapped toErrSessionIDRetired). - Map
ErrSessionIDRetiredtoAlreadyExistson create andNotFoundon update, and ensure the sandbox A2A transport fails fast for retired IDs. - Add Postgres-backed and transport-level tests covering the retired-ID behavior and error-code mapping.
Reviewed changes
Copilot reviewed 9 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| go/core/internal/service/session/service.go | Maps ErrSessionIDRetired to AlreadyExists (create) and NotFound (update). |
| go/core/internal/service/session/service_test.go | Adds unit test verifying service error-code mapping for retired session IDs. |
| go/core/internal/grpcserver/session_task_test.go | Updates generated-client test store to simulate “retired ID” behavior and asserts codes.AlreadyExists. |
| go/core/internal/database/queries/sessions.sql | Updates UpsertSession to skip updates of soft-deleted rows and return id (:one). |
| go/core/internal/database/gen/sessions.sql.go | Regenerates sqlc output for the new UpsertSession return signature and SQL. |
| go/core/internal/database/gen/querier.go | Updates the Querier interface for UpsertSession(ctx, ...) (string, error). |
| go/core/internal/database/client_postgres.go | Maps pgx.ErrNoRows from UpsertSession to dbpkg.ErrSessionIDRetired. |
| go/core/internal/database/client_test.go | Adds Postgres-backed test asserting retired IDs don’t mutate tombstones and don’t write invisibly. |
| go/core/internal/a2a/substrate_sandbox_transport.go | Treats retired session IDs as a hard failure in RoundTrip (no best-effort fallback). |
| go/core/internal/a2a/substrate_sandbox_transport_test.go | Adds tests ensuring both ensureSessionRow and RoundTrip reject retired IDs. |
| go/api/database/client.go | Introduces the shared sentinel ErrSessionIDRetired. |
Files not reviewed (2)
- go/core/internal/database/gen/querier.go: Generated file
- go/core/internal/database/gen/sessions.sql.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // ErrSessionIDRetired means the session id belongs to a soft-deleted session. | ||
| // The id is not reusable: the tombstone and the events and tasks it owns are | ||
| // left as they are. |
There was a problem hiding this comment.
Reworded: the doc comment now says the id stays unusable only while the tombstone exists, and inserts normally again once retention hard-deletes it. 5135a1f
Fixes #2279
Summary
UpsertSessionhad nodeleted_atclause, soON CONFLICT (id, user_id) DO UPDATEmatched soft-deleted rows. Creating a session with a deleted id overwrote the tombstone'sname,agent_idandsourcewhiledeleted_atstayed set. Every read filtersdeleted_at IS NULL, so the write was invisible, including to the create path's own reload, which then returned an internal error. The caller was told the request failed after a write had happened, and the damaged row is the one an audit reads.WHERE session.deleted_at IS NULLto theDO UPDATEand make the query:one, so a rejected write returns no rowsdatabase.ErrSessionIDRetired, whichService.Createreturns asAlreadyExistsandService.UpdateasNotFoundUpsertTaskalready guards the same hazard this way, so the two upserts now matchThe guard lives in the statement rather than a pre-check in the service for two reasons: it is atomic (a delete that commits while the upsert waits on the row lock is still rejected, where a pre-read leaves a window), and it applies to all three
StoreSessioncallers instead of one.Ids are not burned permanently.
DeleteExpiredSessionsBatchhard-deletes tombstones past the retention window, after which the id inserts normally again.Behaviour per id conflict
ChatInterface.tsxis a create against a live id)AlreadyExists, nothing written (this PR)(id, user_id)One path outside the create RPC changes.
ensureSessionRowin the sandbox A2A transport previously performed the invisible write against a retired id and let the request proceed; it now writes nothing andRoundTripfails the request. Continuing would create an actor for an id that can never hold a visible row, so no read path and noService.Deletecould reach it. Other store failures in that best-effort branch still log and proceed.Testing
New Postgres-backed tests:
ErrSessionIDRetired, the tombstone'screated_at,deleted_atandnameare unchanged, and the same id under a differentuser_idstill insertsRoundTripboth reject a retired idAlreadyExistson create andNotFoundon update, and the generated gRPC client seescodes.AlreadyExistsover the wireTestStoreSessionIdempotenceis unchanged and still passes, including its live re-upsert ofname.