Repository navigation
Description
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_number1. - Room A was closed. Room B was created and ran two rounds.
- The candidate's
round_idwas set to room B's round 2 (and itsattemptto 99, to satisfyUNIQUE(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 rulefeedback_status: resolution evidence from a later round- the resume-aware delta watermark in
run_round
room comparegroups candidates byround_idinstead (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_membersreports, whilecandidates=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