refactor(agents): rename Chat Session to Conversation, drop the legacy model - #79
Conversation
Security finding — pre-existing, carried through by this rename, not introduced by itA background review flagged IDOR on What is missing
Not introduced here. The part the review understates The other three handlers — So ownership on this controller is client-asserted throughout. Adding Why it is not fixed in this PR The real fix is to derive the user from the authenticated principal and check that the Conversation belongs to them, on every handler. That is a behaviour change to the auth model, it touches the deprecated alias too, and it would add further breaking findings on top of the ones this PR already has to resolve. Silently changing auth semantics inside a rename is the wrong place for it. Recommend a separate issue and PR. Note the legacy surface being removed here did carry |
6a1f455 to
e597566
Compare
Ownership fix folded in — #80Branch force-updated to 1. The authorization gap from the review above is now fixed here, per #80, rather than in a separate PR — these three handlers were already being reshaped by the rename, so folding it in avoids a second breaking change to the same endpoints. One seam: Refusals return 404, not 403, following Evidence: 616 unit tests, 120 integration including two full-stack cross-user tests against real Postgres covering both the canonical and alias paths. The write-path tests assert The new ArchUnit test asserting every Conversation handler takes the identity header was proved non-vacuous by mutation: removing the header from one handler failed it by name, and pointing the class filter at a non-existent controller failed the 2. History corrected. The previous head carried a 0-byte Spec: 4 new required Correction to the earlier findings summary: three of the fifteen oasdiff errors — |
Contract findings cut from 17 to 11 — still red, and the remainder cannot be fixed honestly
Fixed
Not fixed, deliberately — 11 remaining
So the gate cannot go green for this change without either weakening the ownership fix or publishing a spec that lies. Both are worse than a red check. Eight of the eleven only became visible once the status-code fixes let oasdiff compare matching response codes — previously masked by a 200-vs-201 mismatch being treated as unrelated. They are pre-existing differences surfacing, not new breakage. Full suite green: 619 unit (617 passed, 2 pre-existing skips), 120 integration (118 passed, 2 skipped), detekt and ktlint clean. #80's cross-user tests still pass on both paths, now including the restored messages endpoint. |
…ted alias
ChatSession/ChatMessage become Conversation/ConversationMessage now that
the legacy model occupying that name is gone (previous commit). Tables
rename in place (V27) so existing chats stay available under the new
name. The canonical surface moves to /api/v1/conversations, including a
proper ConversationDetailResponse for GET /{id} in place of the old
untyped session/messages map. /api/v1/chat-sessions stays as a
@deprecated alias with the exact pre-rename shapes -- untyped map
included -- so agents-ui keeps working unchanged; it is removed in a
later release.
GET /conversations/{id}, POST /conversations/{id}/messages and the
/messages/stream handler took no caller identity and returned any
Conversation to any caller. ConversationQueryService.get now takes the
requesting user and returns nothing for another user's Conversation,
identically to an unknown id — the single seam every handler reads
through, so ownership can't be forgotten. Write paths resolve
ownership before dispatching a command or starting a stream, so a
refused request has no side effect. The deprecated chat-sessions alias
delegates to the same service and inherits the check. A refusal logs
at warn with the conversation id and requesting user.
Adds an ArchUnit test asserting every Conversation/ChatSession handler
carries the X-User-Id header, guarded against matching zero classes.
Regenerates the OpenAPI spec: the three handlers now require the
header (the stream endpoints stay hidden from the contract).
…e renamed model
oasdiff flagged 19 breaking changes against main for this branch's reshape
of /api/v1/conversations onto the renamed Conversation model. Restore what
was a design choice rather than a requirement:
- document the real 201/201/204 statuses for create/append/archive instead
of springdoc's inferred 200 (it can't read a dynamic ResponseEntity)
- restore GET /{id}/messages, dropped with the legacy model, serving the
renamed one under the same ownership check as GET /{id} (#80)
- flatten ConversationDetailResponse back to the legacy top-level shape
instead of nesting under a "conversation" key
- accept the legacy "content" request field and make "role" optional on
POST messages, alongside the current "body"/"role"; duplicate "content"
in the message response for the same reason
Left unresolved, and why: title's nullability (renamed model's column is
genuinely nullable) and its request maxLength of 120 (backed by the actual
varchar(120) column, not just an annotation) are real behavioural
differences from the legacy model, not spec inaccuracies -- widening or
lying about either would misrepresent the API. The x-user-id parameter now
required on several endpoints is #80's ownership check, which stays.
`GET /api/v1/chat-sessions/{id}` and `POST /{id}/messages` published no
identity header before #80 added their ownership check, so declaring one
required changed a contract callers already depend on — the last two
findings the contract gate reports.
Declare it optional instead. Absent or unparseable identity refuses
exactly as a wrong one does: 404, no repository read, no dispatch. The
check is unchanged; only its published shape is honest about having been
added later.
Tested, because an optional header is only safe while its absence still
refuses.
b5f5c8a to
d5d0d5f
Compare
Summary
Draft: the contract gate fails by design. Read Validation before merging.
"Session" stops meaning two things. Three commits, deliberately not interleaved — the target name was occupied by the model being deleted, so a single-pass rename would have collided with live files:
conversationandmessagetables.ChatSession→Conversation,ChatMessage→ConversationMessage, withchat_sessions→conversationsandchat_session_messages→conversation_messagesbyALTER TABLE ... RENAME, preserving every row./api/v1/conversationsbecomes canonical;/api/v1/chat-sessionsstays as a deprecated alias on identical shapes, so agents-ui keeps working untouched. Enum constant names are unchanged, since they are persisted by.name.The canonical detail endpoint gains a real
ConversationDetailResponse; the old one returned an untypedMap<String, Any>, giving generated-client callers no type at all. The alias keeps the untyped shape.On #80:
GET /{id},POST /{id}/messagesand the stream endpoint took no caller identity and never checked ownership — anyone with a Conversation id could read the Transcript, append Turns as any role, or drive generation. One seam:ConversationQueryService.getnow takes the requesting user and returns nothing for someone else's Conversation, indistinguishable from an unknown id, so every existing?: notFound()does the work and a new handler cannot skip the check. Writes resolve ownership before dispatching, so a refused append leaves no trace. 404 not 403, followingWorkspaceRunnerEventsController— a 403 confirms the id exists. The alias inherits the same check.Validation
:api:test— 616 tests, 614 passed, 0 failed, 2 pre-existing skips:api:integrationTest— 120 tests, 118 passed, 0 failed, 2 skipped--warning-mode=fail— clean--rerun-tasksTwo cross-user integration tests run against real Postgres on both the canonical and alias paths. The write-path unit tests assert
commandBus.dispatchandchatAnswerStream.streamare called zero times on refusal, not merely that the status was 404.The
contractjob fails.oasdiff breakingwithfail-on: WARNreports 15 errors and 2 warnings, every one on/api/v1/conversations*— the legacy replacement — plus 4 new requiredX-User-Idheaders. Zero findings on/api/v1/chat-sessions: the alias removed all avoidable breakage.api-contract-checksexposes no waiver and I have not touched the gate. Full output in the PR comments.Of those 15 errors, 3 are pre-existing springdoc drift, not behaviour change: create/message/delete are documented 200 while returning 201/204, because springdoc cannot infer status from a dynamically built
ResponseEntity.main's/chat-sessionsspec already has this; the move just makes oasdiff compare it against the legacy controller's annotated version.Notes
startsWith("ChatSession"); the other matched@Service, an annotation unique to the three deleted handlers. Both now match on interfaces. The new test asserting every Conversation handler takes the identity header was proved non-vacuous by mutation — pointing its filter at a non-existent controller fails theisNotEmpty()guard rather than passing with nothing checked.api-contract-checks, a one-off merge past red, or keeping the legacy endpoints alive. The third contradicts Rename Chat Session to Conversation and split navigation into Workspaces and Conversations #68's second criterion.refs #68, refs #80