Skip to content

Audit trail silently drops conversations/branches: conversation_id never rotates across shinychat history operations #106

Description

@cpsievert

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:

  1. 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.
  2. 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.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions