Skip to content

A candidate whose round_id names another room's round is read with that round's number #293

Open

Description

@memtomem

What happens

RoomStore._list_candidates (src/syncmill/rooms.py) reads a room's candidates with

SELECT c.*, r.number AS round_number FROM candidates c
JOIN rounds r ON r.id = c.round_id
WHERE c.room_id=?

The join never checks r.room_id = c.room_id. A candidate row whose round_id names a round of another room is listed under its own room, carrying that other round's number.

Measured on main @ cb030c0, with fake agents:

  • Room A ran one round. Its candidate reads round_number 1.
  • Room A was closed. Room B was created and ran two rounds.
  • The candidate's round_id was set to room B's round 2 (and its attempt to 99, to satisfy UNIQUE(round_id, member_id, attempt)).
Read Result
list_rounds(A) [1]
candidate_rows(A)[cand]["round_number"] 2
list_candidates(B) does not include the candidate
load_candidate(cand) RoomError: candidate round_id binding mismatch

So the artifact path refuses: _validate_candidate_bindings compares the row with the manifest. But every reader that works from SQLite rows alone takes the borrowed number at face value. That includes:

  • round_provenance_status, through _candidate_rows: repair parent order and branch/continued parents
  • _repair_parent_problem: the newest-completed-candidate rule
  • feedback_status: resolution evidence from a later round
  • the resume-aware delta watermark in run_round
    room compare groups candidates by round_id instead (room_compare.py, by_round), so there the candidate falls out of every round of its room rather than moving to round 2. Its own round then lacks it, which _missing_members reports, while candidates= passed to the provenance and conversation validators still holds the borrowed number. This last part was read from the code, not measured.

Scope

Reaching this needs a direct edit of rooms.db. CLAUDE.md's trust model says the state root blocks traversal and redirection, not a same-credential actor. So this is hardening, not an exploitable path. Found during the #173 PR 2 review (#292), which made feedback reads fail closed on damaged room_feedback rows. It was left out there because this is existing candidate-reading code.

Possible fix

Add AND r.room_id = c.room_id to the join, or select r.room_id and treat a mismatch as a damaged row. A plain join filter would make the candidate silently disappear from its room's listing. That would turn a wrong number into a missing candidate, which _missing_members would then report. Which of the two failure shapes the read models should show is the decision.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions