Problem
Commons mints private$conversation_id exactly once, in initialize(), and nothing ever rotates it. But shinychat's history controller reuses one client object for every logical conversation in a session:
- New chat (
HistoryController$new_chat): client$set_turns(list()) — same client.
- Conversation switching (
switch_to): set_turns() to a completely different stored conversation — same client.
- Edit (
handle_edit): truncates turns to the fork parent via set_turns(), then resubmits.
- Branch navigation (
handle_navigate): set_turns() to a different leaf of the message tree.
So in the trace, every conversation a user touches in one Shiny session is recorded under a single commons.conversation.id.
Impact: exchanges silently vanish from the audit trail
build_trajectories() keeps only the latest chat span per conversation id as the canonical history. Two consequences:
- New chat / switch: a user has a 5-exchange conversation, clicks "New chat", asks one question. The latest chat span under the shared id carries only the new conversation's turns — the first conversation's five exchanges disappear from
trajectory_read() entirely.
- Edits / branch navigation: after an edit-and-regenerate (or navigating back to an earlier sibling and continuing), only the final active path survives review. Answers the user actually saw — rendered with a trust verdict — become unreviewable.
For a governance/audit tool this is the worst kind of gap: it's silent. The reviewer has no signal that anything is missing.
Note the exchange-provenance matching added in the server-side citation verification work already anticipates the id-sharing (see "switched conversations do not donate audit records" and "edited paths retain shared-prefix records and drop abandoned records" in test-trajectories.R) — records are correctly never cross-contaminated between logical conversations. That defends the records; the turn content still evaporates via latest-span-wins.
Recommended fix
Rotate conversation_id whenever history diverges. Have Commons override set_turns() (or detect at stream_async() time) that the current turns are not an extension of the last-streamed state, and mint a new id.
Because every chat span carries its full history prefix, the new id's spans still carry the restored/forked history — nothing is lost, and latest_chat_spans() stops collapsing distinct conversations.
Trade-off: one logical conversation restored across sessions appears under multiple ids, so review shows some duplicated prefix history. Duplication beats disappearance for an audit tool, and the prefix-comparison machinery in trajectory-read.R could dedupe later.
Alternative / complement
build_trajectories() already parses every recorded call's chat span (post citation-verification branch). It could detect that some spans are not prefixes of the latest one — i.e., abandoned branches or displaced conversations — and surface them, even just as "N branches/conversations not shown", closing the silent part of the gap without a UI redesign.
Related
- Mid-stream mutation race (smaller, separate): shinychat's
handle_edit/new_chat/switch_to observers are not gated on stream status, so turns can be mutated between stream_async()'s from_index capture and its completion-time self$get_turns() read, recording a tag computed from unrelated turns. Deriving tags from content that flowed through the stream itself would remove the race.
(Observed against shinychat 0.4.0.9000.)
Problem
Commonsmintsprivate$conversation_idexactly once, ininitialize(), and nothing ever rotates it. But shinychat's history controller reuses one client object for every logical conversation in a session:HistoryController$new_chat):client$set_turns(list())— same client.switch_to):set_turns()to a completely different stored conversation — same client.handle_edit): truncates turns to the fork parent viaset_turns(), then resubmits.handle_navigate):set_turns()to a different leaf of the message tree.So in the trace, every conversation a user touches in one Shiny session is recorded under a single
commons.conversation.id.Impact: exchanges silently vanish from the audit trail
build_trajectories()keeps only the latest chat span per conversation id as the canonical history. Two consequences:trajectory_read()entirely.For a governance/audit tool this is the worst kind of gap: it's silent. The reviewer has no signal that anything is missing.
Note the exchange-provenance matching added in the server-side citation verification work already anticipates the id-sharing (see "switched conversations do not donate audit records" and "edited paths retain shared-prefix records and drop abandoned records" in
test-trajectories.R) — records are correctly never cross-contaminated between logical conversations. That defends the records; the turn content still evaporates via latest-span-wins.Recommended fix
Rotate
conversation_idwhenever history diverges. HaveCommonsoverrideset_turns()(or detect atstream_async()time) that the current turns are not an extension of the last-streamed state, and mint a new id.Because every chat span carries its full history prefix, the new id's spans still carry the restored/forked history — nothing is lost, and
latest_chat_spans()stops collapsing distinct conversations.Trade-off: one logical conversation restored across sessions appears under multiple ids, so review shows some duplicated prefix history. Duplication beats disappearance for an audit tool, and the prefix-comparison machinery in
trajectory-read.Rcould dedupe later.Alternative / complement
build_trajectories()already parses every recorded call's chat span (post citation-verification branch). It could detect that some spans are not prefixes of the latest one — i.e., abandoned branches or displaced conversations — and surface them, even just as "N branches/conversations not shown", closing the silent part of the gap without a UI redesign.Related
handle_edit/new_chat/switch_toobservers are not gated on stream status, so turns can be mutated betweenstream_async()'sfrom_indexcapture and its completion-timeself$get_turns()read, recording a tag computed from unrelated turns. Deriving tags from content that flowed through the stream itself would remove the race.(Observed against shinychat 0.4.0.9000.)