{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[e52a5895] Replace vestigial vote identity with pseudonym-based uuid dedup.\n\nStore votes in uuid_votes (not blind edge merges), derive rankings on load, and resolve actors via a pseudonyms map seeded at projection open.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/server/src/bin/storage_bench.rs b/server/src/bin/storage_bench.rs\nindex 3d988416ad36b27a7d3dc84280cfdbdcafa43e69..cc5cd78ba1e02a1e6f92aa503677f2d329bb5993 100644\n--- a/server/src/bin/storage_bench.rs\n+++ b/server/src/bin/storage_bench.rs\n@@ -29,14 +29,7 @@ async fn main() -> Result<(), Box> {\n for chunk_start in (0..opts.events).step_by(opts.batch_size) {\n let chunk_end = (chunk_start + opts.batch_size).min(opts.events);\n let events = (chunk_start..chunk_end)\n- .map(|i| Event::VoteRecorded {\n- ts: i as i64,\n- a: format!(\"item-{i}\"),\n- b: format!(\"item-{}\", i + 1),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- })\n+ .map(|i| Event::vote_recorded(i as i64, format!(\"item-{i}\"), format!(\"item-{}\", i + 1), 2, 1, \"\"))\n .collect();\n journal.append_many(events).await?;\n }\ndiff --git a/server/src/event_log.rs b/server/src/event_log.rs\nindex 2c9290b6fdbf2c2ad1c0f1ffd7374b2d9cc97f36..8e5684cb1378e80c45bcf743d2fdc48402dd4dc8 100644\n--- a/server/src/event_log.rs\n+++ b/server/src/event_log.rs\n@@ -206,14 +206,7 @@ mod tests {\n .unwrap();\n log.append(&sample_record(\n 2,\n- Event::VoteRecorded {\n- ts: 1,\n- a: \"a\".into(),\n- b: \"b\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- },\n+ Event::vote_recorded(1, \"a\", \"b\", 2, 1, \"\"),\n ))\n .await\n .unwrap();\ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex d76c3bb4277216b0d39c9422ba7a50db10a95e05..8a166d49b4f26835fbc2b58cb1f4bdbf002763b8 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -1,7 +1,7 @@\n use serde::{Deserialize, Serialize};\n \n /// Schema version for JSONL log records. Bump when event semantics change.\n-pub const CURRENT_LOG_SCHEMA: u32 = 1;\n+pub const CURRENT_LOG_SCHEMA: u32 = 2;\n \n /// One JSONL line: schema envelope around a payload event.\n #[derive(Debug, Clone, Serialize, Deserialize)]\n@@ -51,9 +51,33 @@ pub enum Event {\n b: String,\n ratio_left: i32,\n ratio_right: i32,\n- #[serde(default)]\n scope: String,\n+ pseudonym: String,\n+ trust_weight: f64,\n },\n /// Register a node path in the fractal tree (no external fetch).\n NodeEnsured { id: String },\n }\n+\n+impl Event {\n+ /// Construct a vote event with the default dev pseudonym (tests and benches).\n+ pub fn vote_recorded(\n+ ts: i64,\n+ a: impl Into,\n+ b: impl Into,\n+ ratio_left: i32,\n+ ratio_right: i32,\n+ scope: impl Into,\n+ ) -> Self {\n+ Self::VoteRecorded {\n+ ts,\n+ a: a.into(),\n+ b: b.into(),\n+ ratio_left,\n+ ratio_right,\n+ scope: scope.into(),\n+ pseudonym: crate::identity::DEFAULT_PSEUDONYM.to_string(),\n+ trust_weight: 1.0,\n+ }\n+ }\n+}\ndiff --git a/server/src/html/vote.rs b/server/src/html/vote.rs\nindex 8cf2b0d4d8bd58cc51cc27c9046fa7f7728a7017..bf82aef3ad9c5f4e9e877c47dab27beb29a80b8f 100644\n--- a/server/src/html/vote.rs\n+++ b/server/src/html/vote.rs\n@@ -336,6 +336,7 @@ pub async fn vote_page(\n #[cfg(test)]\n mod polarity_tests {\n use super::*;\n+ use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};\n use crate::ranking::ranked_items;\n use crate::reducer::GlobalTree;\n \n@@ -351,11 +352,11 @@ mod polarity_tests {\n let right = id(\"right_item\");\n \n // Stored a == page left: keep order.\n- let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ let v1 = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();\n assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1));\n \n // Stored a == page right: swap so left stays left.\n- let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap();\n+ let v2 = VoteData::from_event(2, right.as_str(), left.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();\n assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9));\n }\n \n@@ -383,9 +384,9 @@ mod polarity_tests {\n let right = id(\"right_item\");\n \n // Slider dragged left yields e.g. 9:1 with a = left item.\n- let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ let vote = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();\n let mut tree = GlobalTree::new();\n- tree.apply_vote(&parent, vote);\n+ tree.apply_vote(&parent, vote, TEST_ACTOR_UUID);\n \n let group = &tree.get(&parent).unwrap().local_ranking;\n let ranked = ranked_items(group);\ndiff --git a/server/src/identity.rs b/server/src/identity.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..621d505436912246ec0830ff20c3473f0c4665ce\n--- /dev/null\n+++ b/server/src/identity.rs\n@@ -0,0 +1,35 @@\n+//! Actor identity: pseudonym display names mapped to stable UUIDs for vote dedup.\n+\n+use durable::Db;\n+\n+use crate::storage_schema::{Store, StoreFields};\n+\n+/// Default pseudonym until session/auth UI exists.\n+pub const DEFAULT_PSEUDONYM: &str = \"anon\";\n+\n+/// UUID for the default single-user dev principal.\n+pub const DEFAULT_ACTOR_UUID: &str = \"00000000-0000-0000-0000-000000000001\";\n+\n+/// UUID for in-memory unit tests.\n+pub const TEST_ACTOR_UUID: &str = \"00000000-0000-0000-0000-000000000099\";\n+\n+/// Resolve the trust anchor for a pseudonym (must exist in the pseudonyms map).\n+pub fn resolve_actor_uuid(db: &Db, pseudonym: &str) -> Result {\n+ Store::root()\n+ .pseudonyms()\n+ .key(&pseudonym.to_string())\n+ .get(db)\n+ .map_err(|e| e.to_string())?\n+ .ok_or_else(|| format!(\"unknown pseudonym: {pseudonym}\"))\n+}\n+\n+/// Ensure the default pseudonym → UUID mapping exists (operational seed, not event-logged).\n+pub fn seed_default_pseudonym(db: &Db) -> Result<(), durable::Error> {\n+ let path = Store::root()\n+ .pseudonyms()\n+ .key(&DEFAULT_PSEUDONYM.to_string());\n+ if path.get(db)?.is_none() {\n+ db.run(path.set(&DEFAULT_ACTOR_UUID.to_string()), durable::Durability::SyncWal)?;\n+ }\n+ Ok(())\n+}\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 3dfc7c8acb8ed61bb73ade63e72768e402042cc5..da6e33e3dd6d79967bbee7a2708a7d982c93b88c 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -4,6 +4,7 @@ pub mod events;\n pub mod fetch;\n pub mod form_template;\n pub mod html;\n+pub mod identity;\n pub mod journal;\n pub mod pair;\n pub mod parser;\ndiff --git a/server/src/pair.rs b/server/src/pair.rs\nindex 2277e32edf6e0024687d6fe2b9d1a9b84c0b34c8..42a1b1eb2adf16730d34d0fe23c13d5a75d7ba27 100644\n--- a/server/src/pair.rs\n+++ b/server/src/pair.rs\n@@ -360,8 +360,17 @@ impl PairError {\n #[cfg(test)]\n mod tests {\n use super::*;\n+ use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};\n use crate::reducer::{GlobalTree, VoteData};\n \n+ fn test_vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n+ VoteData::from_event(ts, a, b, l, r, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap()\n+ }\n+\n+ fn apply(tree: &mut GlobalTree, parent: &ItemId, vote: VoteData) {\n+ tree.apply_vote(parent, vote, TEST_ACTOR_UUID);\n+ }\n+\n fn seed_children(parent: &ItemId, ids: &[&str]) -> GlobalTree {\n let mut tree = GlobalTree::new();\n tree.ensure_path(parent);\n@@ -382,22 +391,13 @@ mod tests {\n #[test]\n fn zero_weight_vote_leaves_pair_available_for_suggestion() {\n let parent = ItemId::parse(\"https://reddit.com/r/rust\").unwrap();\n- let mut tree = seed_children(\n+ let tree = seed_children(\n &parent,\n &[\n \"https://reddit.com/r/rust/a\",\n \"https://reddit.com/r/rust/b\",\n ],\n );\n- let noop = VoteData::from_recorded(\n- 1,\n- \"https://reddit.com/r/rust/a\",\n- \"https://reddit.com/r/rust/b\",\n- 0,\n- 0,\n- )\n- .unwrap();\n- tree.apply_vote(&parent, noop);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n assert!(!pair_is_voted(&group, &pool[0], &pool[1]));\n@@ -415,9 +415,8 @@ mod tests {\n \"https://reddit.com/r/rust/c\",\n ],\n );\n- let vote =\n- VoteData::from_recorded(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1).unwrap();\n- tree.apply_vote(&parent, vote);\n+ let vote = test_vote(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1);\n+ apply(&mut tree, &parent, vote);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n@@ -438,12 +437,10 @@ mod tests {\n \"https://reddit.com/r/rust/d\",\n ],\n );\n- let ab =\n- VoteData::from_recorded(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1).unwrap();\n- let cd =\n- VoteData::from_recorded(2, \"https://reddit.com/r/rust/c\", \"https://reddit.com/r/rust/d\", 2, 1).unwrap();\n- tree.apply_vote(&parent, ab);\n- tree.apply_vote(&parent, cd);\n+ let ab = test_vote(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1);\n+ let cd = test_vote(2, \"https://reddit.com/r/rust/c\", \"https://reddit.com/r/rust/d\", 2, 1);\n+ apply(&mut tree, &parent, ab);\n+ apply(&mut tree, &parent, cd);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n@@ -468,9 +465,8 @@ mod tests {\n \"https://reddit.com/r/rust/e\",\n ],\n );\n- let ab =\n- VoteData::from_recorded(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1).unwrap();\n- tree.apply_vote(&parent, ab);\n+ let ab = test_vote(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1);\n+ apply(&mut tree, &parent, ab);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n@@ -498,9 +494,8 @@ mod tests {\n \"https://reddit.com/r/rust/c\",\n ],\n );\n- let ab =\n- VoteData::from_recorded(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1).unwrap();\n- tree.apply_vote(&parent, ab);\n+ let ab = test_vote(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1);\n+ apply(&mut tree, &parent, ab);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n@@ -524,8 +519,8 @@ mod tests {\n (\"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 3, 1),\n (\"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/c\", 2, 1),\n ] {\n- let v = VoteData::from_recorded(1, a, b, l, r).unwrap();\n- tree.apply_vote(&parent, v);\n+ let v = test_vote(1, a, b, l, r);\n+ apply(&mut tree, &parent, v);\n }\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n@@ -552,8 +547,8 @@ mod tests {\n (\"https://reddit.com/r/rust/b\", \"https://reddit.com/r/rust/c\", 2, 1),\n (\"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/c\", 2, 1),\n ] {\n- let v = VoteData::from_recorded(1, a, b, l, r).unwrap();\n- tree.apply_vote(&parent, v);\n+ let v = test_vote(1, a, b, l, r);\n+ apply(&mut tree, &parent, v);\n }\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\ndiff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs\nindex ad404bacb8bcdd5ae0e682cff97f974fd44528ea..f2859c7e0e718c5bd3d332224eb05d7a8c65a816 100644\n--- a/server/src/projection_apply.rs\n+++ b/server/src/projection_apply.rs\n@@ -1,14 +1,13 @@\n //! Apply event-log records to the durable projection as precise point updates.\n //!\n-//! Each batch of records lowers to reified durable writes (edge merges, child\n-//! links, voted-pair flags, recent-vote pushes) plus a cursor advance, all\n-//! committed in one atomic `DisableWal` batch. The cursor moving in the same\n-//! batch as the (non-idempotent) edge merges guarantees exactly-once application\n-//! across replay.\n+//! Each batch of records lowers to reified durable writes (uuid vote upserts,\n+//! child links, recent-vote appends) plus a cursor advance, all committed in\n+//! one atomic `DisableWal` batch.\n \n use crate::{\n event_log::EventLogError,\n events::{Event, EventRecord},\n+ identity::resolve_actor_uuid,\n path_types::ItemId,\n projection_store::ProjectionStore,\n reducer::VoteData,\n@@ -53,20 +52,31 @@ pub fn apply_records(\n ratio_left,\n ratio_right,\n scope,\n+ pseudonym,\n+ trust_weight,\n } => {\n- let vote = VoteData::from_recorded(*ts, a, b, *ratio_left, *ratio_right)\n- .ok_or_else(|| EventLogError::Apply(format!(\"invalid vote event: {a} vs {b}\")))?;\n- let parent = parent_from_event_scope(scope);\n- vote_writes(\n- &mut batch,\n- &parent,\n- vote.a.as_str(),\n- vote.b.as_str(),\n- *ratio_left,\n- *ratio_right,\n+ let left = (*ratio_left).max(0);\n+ let right = (*ratio_right).max(0);\n+ if left == 0 && right == 0 {\n+ return Err(EventLogError::Apply(format!(\n+ \"invalid vote event: zero weights ({a} vs {b})\"\n+ )));\n+ }\n+ let vote = VoteData::from_event(\n *ts,\n+ a,\n+ b,\n+ left,\n+ right,\n+ pseudonym.clone(),\n+ *trust_weight,\n )\n- .map_err(|e| EventLogError::Apply(e.to_string()))?;\n+ .ok_or_else(|| EventLogError::Apply(format!(\"invalid vote event: {a} vs {b}\")))?;\n+ let actor_uuid = resolve_actor_uuid(db, pseudonym)\n+ .map_err(|e| EventLogError::Apply(e))?;\n+ let parent = parent_from_event_scope(scope);\n+ vote_writes(&mut batch, &parent, &vote, &actor_uuid)\n+ .map_err(|e| EventLogError::Apply(e.to_string()))?;\n }\n Event::NodeEnsured { id } => {\n let parsed = parse_event_id(id)?;\ndiff --git a/server/src/projection_store.rs b/server/src/projection_store.rs\nindex 9a8953d010029d3639dc3987687554bab8b7663e..27fc0bb0519d9f035dceec1fc8b0fa74b00b6b40 100644\n--- a/server/src/projection_store.rs\n+++ b/server/src/projection_store.rs\n@@ -18,7 +18,7 @@ use crate::{\n \n const PROJECTION_CURSOR_KEY: &str = \"cursor\";\n const PROJECTION_SCHEMA_KEY: &str = \"schema_version\";\n-const PROJECTION_SCHEMA_VERSION: u64 = 4;\n+const PROJECTION_SCHEMA_VERSION: u64 = 5;\n \n #[derive(Debug, thiserror::Error)]\n pub enum ProjectionStoreError {\n@@ -51,6 +51,8 @@ impl ProjectionStore {\n if version != Some(PROJECTION_SCHEMA_VERSION) {\n store.reset()?;\n }\n+ crate::identity::seed_default_pseudonym(db)\n+ .map_err(ProjectionStoreError::Durable)?;\n Ok(store)\n }\n \n@@ -206,14 +208,7 @@ mod tests {\n let db = Db::open(tmp.path()).unwrap();\n let store = ProjectionStore::from_db(&db).unwrap();\n \n- let event = Event::VoteRecorded {\n- ts: 1,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- };\n+ let event = Event::vote_recorded(1, \"alpha\", \"beta\", 2, 1, \"\");\n projection_apply::apply_records(&store, &[record(1, event)]).unwrap();\n assert_eq!(store.last_applied_event_count().unwrap(), 1);\n \n@@ -229,14 +224,7 @@ mod tests {\n let db = Db::open(tmp.path()).unwrap();\n let store = ProjectionStore::from_db(&db).unwrap();\n \n- let event = Event::VoteRecorded {\n- ts: 1,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- };\n+ let event = Event::vote_recorded(1, \"alpha\", \"beta\", 2, 1, \"\");\n projection_apply::apply_records(&store, &[record(1, event)]).unwrap();\n \n let scoped = store.scope_tree(&ItemId::root()).unwrap();\ndiff --git a/server/src/ranking.rs b/server/src/ranking.rs\nindex a83f7be120d9420db042479222ca9efc230b6da9..1fc3298d2b2864e9711cd262a034677e45c7090c 100644\n--- a/server/src/ranking.rs\n+++ b/server/src/ranking.rs\n@@ -271,6 +271,7 @@ pub fn group_summary_scores(group: &GroupState) -> HashMap {\n #[cfg(test)]\n mod tests {\n use super::*;\n+ use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};\n use crate::reducer::VoteData;\n \n fn mk_group() -> GroupState {\n@@ -278,18 +279,11 @@ mod tests {\n }\n \n fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n- use crate::path_types::ItemId;\n- VoteData {\n- ts,\n- a: ItemId::parse(a).unwrap(),\n- b: ItemId::parse(b).unwrap(),\n- ratio_left: l,\n- ratio_right: r,\n- body: \"because\".to_string(),\n- principal: \"test\".to_string(),\n- delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n- thread_tag: \"untagged\".to_string(),\n- }\n+ VoteData::from_event(ts, a, b, l, r, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap()\n+ }\n+\n+ fn apply(g: &mut GroupState, v: VoteData) {\n+ g.apply_vote(v, TEST_ACTOR_UUID);\n }\n \n /// Regression for issue #146: pure forward star at default `>` ratio (2:1).\n@@ -301,8 +295,8 @@ mod tests {\n #[test]\n fn star_topology_winner_at_top_via_subset() {\n let mut g = mk_group();\n- g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n- g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n+ g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1), TEST_ACTOR_UUID);\n+ g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1), TEST_ACTOR_UUID);\n \n let mut items: Vec<(usize, String)> = g\n .idx_to_item\n@@ -329,7 +323,7 @@ mod tests {\n let mut g = mk_group();\n // Chain a > b > c > d > e > f so ranks are well separated.\n for (hi, lo) in [(\"a\", \"b\"), (\"b\", \"c\"), (\"c\", \"d\"), (\"d\", \"e\"), (\"e\", \"f\")] {\n- g.apply_vote(vote(1, hi, lo, 2, 1));\n+ apply(&mut g, vote(1, hi, lo, 2, 1));\n }\n let (top, bottom) = top_bottom(&g, 2);\n assert_eq!(top.len(), 2);\n@@ -345,7 +339,7 @@ mod tests {\n #[test]\n fn top_bottom_small_group_has_empty_bottom() {\n let mut g = mk_group();\n- g.apply_vote(vote(1, \"a\", \"b\", 2, 1));\n+ apply(&mut g, vote(1, \"a\", \"b\", 2, 1));\n let (top, bottom) = top_bottom(&g, 5);\n assert_eq!(top.len(), 2);\n assert!(bottom.is_empty());\n@@ -355,8 +349,8 @@ mod tests {\n fn connected_components_split_disconnected_pairs() {\n let mut g = mk_group();\n // Two disconnected edges: (a,b) and (c,d)\n- g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n- g.apply_vote(vote(2, \"c\", \"d\", 3, 1));\n+ apply(&mut g, vote(1, \"a\", \"b\", 3, 1));\n+ apply(&mut g, vote(2, \"c\", \"d\", 3, 1));\n \n let n = g.idx_to_item.len();\n let (mut comps, isolates) =\n@@ -401,13 +395,16 @@ mod tests {\n let i = *perm[..k].choose(&mut rng).unwrap();\n let j = perm[k];\n let (a, b) = (letters[i], letters[j]);\n- g.apply_vote(vote(\n- k as i64,\n- &a.to_string(),\n- &b.to_string(),\n- (i + 1) as i32,\n- (j + 1) as i32,\n- ));\n+ apply(\n+ &mut g,\n+ vote(\n+ k as i64,\n+ &a.to_string(),\n+ &b.to_string(),\n+ (i + 1) as i32,\n+ (j + 1) as i32,\n+ ),\n+ );\n }\n \n let ranked = ranked_items(&g);\n@@ -426,8 +423,8 @@ mod tests {\n #[test]\n fn subset_ranking_ranks_within_component_only() {\n let mut g = mk_group();\n- g.apply_vote(vote(1, \"a\", \"b\", 3, 1)); // a > b\n- g.apply_vote(vote(2, \"c\", \"d\", 1, 4)); // d > c\n+ apply(&mut g, vote(1, \"a\", \"b\", 3, 1)); // a > b\n+ apply(&mut g, vote(2, \"c\", \"d\", 1, 4)); // d > c\n \n let (comps, _) = connected_components_from_voted_pairs(\n g.idx_to_item.len(),\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 759918b8c0eb8f8bf1ed0911d8877adaa55c8ea6..8c4c9f83635cbcbb037d680dbae2aa99cb2dc785 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -5,27 +5,27 @@ use serde::{Deserialize, Serialize};\n use crate::path_types::ItemId;\n \n /// Parsed pairwise vote (internal representation).\n-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n pub struct VoteData {\n pub ts: i64,\n pub a: ItemId,\n pub b: ItemId,\n pub ratio_left: i32,\n pub ratio_right: i32,\n- pub body: String,\n- pub principal: String,\n- pub delegate: Option,\n- pub thread_tag: String,\n+ pub pseudonym: String,\n+ pub trust_weight: f64,\n }\n \n impl VoteData {\n- /// Build a vote from persisted event fields (web UI / replay).\n- pub fn from_recorded(\n+ /// Build a vote from validated event fields (replay / tests).\n+ pub fn from_event(\n ts: i64,\n a: &str,\n b: &str,\n ratio_left: i32,\n ratio_right: i32,\n+ pseudonym: String,\n+ trust_weight: f64,\n ) -> Option {\n let a = ItemId::from_storage(a)?;\n let b = ItemId::from_storage(b)?;\n@@ -38,10 +38,8 @@ impl VoteData {\n b,\n ratio_left,\n ratio_right,\n- body: String::new(),\n- principal: \"web\".to_string(),\n- delegate: None,\n- thread_tag: \"default\".to_string(),\n+ pseudonym,\n+ trust_weight,\n })\n }\n }\n@@ -52,6 +50,8 @@ pub struct GroupState {\n pub idx_to_item: Vec,\n pub edges: HashMap<(usize, usize), f64>,\n pub voted_pairs: HashSet<(usize, usize)>,\n+ /// Latest vote per `(actor_uuid, min_idx, max_idx)` — Sybil dedup anchor.\n+ pub uuid_votes: HashMap<(String, usize, usize), VoteData>,\n pub recent_votes: Vec,\n }\n \n@@ -62,6 +62,7 @@ impl GroupState {\n idx_to_item: Vec::new(),\n edges: HashMap::new(),\n voted_pairs: HashSet::new(),\n+ uuid_votes: HashMap::new(),\n recent_votes: Vec::new(),\n }\n }\n@@ -83,36 +84,78 @@ impl GroupState {\n *self.edges.entry((src, dst)).or_insert(0.0) += w;\n }\n \n- pub fn apply_vote(&mut self, mut vote: VoteData) {\n- vote.a = ItemId::from_storage(vote.a.as_str()).unwrap_or(vote.a.clone());\n- vote.b = ItemId::from_storage(vote.b.as_str()).unwrap_or(vote.b.clone());\n- if vote.ratio_left < 0 {\n- vote.ratio_left = 0;\n- }\n- if vote.ratio_right < 0 {\n- vote.ratio_right = 0;\n- }\n- if vote.ratio_left == 0 && vote.ratio_right == 0 {\n+ fn subtract_edge_weight(&mut self, src: usize, dst: usize, w: f64) {\n+ if w <= 0.0 {\n return;\n }\n- let a_idx = self.ensure_item(&vote.a);\n- let b_idx = self.ensure_item(&vote.b);\n+ if let Some(entry) = self.edges.get_mut(&(src, dst)) {\n+ *entry -= w;\n+ if *entry <= 0.0 {\n+ self.edges.remove(&(src, dst));\n+ }\n+ }\n+ }\n \n+ fn apply_weights(&mut self, vote: &VoteData, a_idx: usize, b_idx: usize) {\n+ let w_a = vote.ratio_left as f64 * vote.trust_weight;\n+ let w_b = vote.ratio_right as f64 * vote.trust_weight;\n let (i, j) = if a_idx < b_idx {\n (a_idx, b_idx)\n } else {\n (b_idx, a_idx)\n };\n-\n- let w_a = vote.ratio_left as f64;\n- let w_b = vote.ratio_right as f64;\n-\n self.voted_pairs.insert((i, j));\n self.add_edge_weight(b_idx, a_idx, w_a);\n self.add_edge_weight(a_idx, b_idx, w_b);\n+ }\n \n+ fn rollback_weights(&mut self, vote: &VoteData) {\n+ let a_idx = match self.item_to_idx.get(&vote.a) {\n+ Some(&i) => i,\n+ None => return,\n+ };\n+ let b_idx = match self.item_to_idx.get(&vote.b) {\n+ Some(&i) => i,\n+ None => return,\n+ };\n+ let w_a = vote.ratio_left as f64 * vote.trust_weight;\n+ let w_b = vote.ratio_right as f64 * vote.trust_weight;\n+ self.subtract_edge_weight(b_idx, a_idx, w_a);\n+ self.subtract_edge_weight(a_idx, b_idx, w_b);\n+ }\n+\n+ /// Apply a validated vote, deduplicating by `actor_uuid` per unordered pair.\n+ pub fn apply_vote(&mut self, vote: VoteData, actor_uuid: &str) {\n+ let a_idx = self.ensure_item(&vote.a);\n+ let b_idx = self.ensure_item(&vote.b);\n+ let (i, j) = if a_idx < b_idx {\n+ (a_idx, b_idx)\n+ } else {\n+ (b_idx, a_idx)\n+ };\n+\n+ let dedupe_key = (actor_uuid.to_string(), i, j);\n+ if let Some(old) = self.uuid_votes.get(&dedupe_key).cloned() {\n+ self.rollback_weights(&old);\n+ }\n+\n+ self.apply_weights(&vote, a_idx, b_idx);\n+ self.uuid_votes.insert(dedupe_key, vote.clone());\n self.recent_votes.push(vote);\n }\n+\n+ /// Rebuild edge weights from deduped uuid votes (load path — no rollback).\n+ pub fn ingest_uuid_vote(&mut self, vote: VoteData, actor_uuid: &str) {\n+ let a_idx = self.ensure_item(&vote.a);\n+ let b_idx = self.ensure_item(&vote.b);\n+ let (i, j) = if a_idx < b_idx {\n+ (a_idx, b_idx)\n+ } else {\n+ (b_idx, a_idx)\n+ };\n+ self.apply_weights(&vote, a_idx, b_idx);\n+ self.uuid_votes.insert((actor_uuid.to_string(), i, j), vote);\n+ }\n }\n \n /// Structured data imported from Reddit or elsewhere.\n@@ -192,14 +235,14 @@ impl GlobalTree {\n self.nodes.get(id)\n }\n \n- pub fn apply_vote(&mut self, parent: &ItemId, vote: VoteData) {\n+ pub fn apply_vote(&mut self, parent: &ItemId, vote: VoteData, actor_uuid: &str) {\n self.ensure_path(parent);\n self.ensure_path(&vote.a);\n self.ensure_path(&vote.b);\n if let Some(node) = self.nodes.get_mut(parent) {\n node.children.insert(vote.a.clone());\n node.children.insert(vote.b.clone());\n- node.local_ranking.apply_vote(vote);\n+ node.local_ranking.apply_vote(vote, actor_uuid);\n }\n }\n \n@@ -231,36 +274,62 @@ impl GlobalTree {\n }\n \n #[cfg(test)]\n-mod from_recorded_tests {\n+mod tests {\n use super::*;\n \n+ fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32, pseudonym: &str) -> VoteData {\n+ VoteData {\n+ ts,\n+ a: ItemId::opaque(a),\n+ b: ItemId::opaque(b),\n+ ratio_left: l,\n+ ratio_right: r,\n+ pseudonym: pseudonym.to_string(),\n+ trust_weight: 1.0,\n+ }\n+ }\n+\n #[test]\n- fn rejects_same_item() {\n- assert!(VoteData::from_recorded(1, \"a\", \"a\", 2, 1).is_none());\n+ fn from_event_rejects_same_item() {\n+ assert!(VoteData::from_event(\n+ 1,\n+ \"a\",\n+ \"a\",\n+ 2,\n+ 1,\n+ \"anon\".into(),\n+ 1.0\n+ )\n+ .is_none());\n }\n \n #[test]\n- fn rejects_empty_pair() {\n- assert!(VoteData::from_recorded(1, \"\", \"b\", 2, 1).is_none());\n+ fn from_event_rejects_empty_pair() {\n+ assert!(VoteData::from_event(1, \"\", \"b\", 2, 1, \"anon\".into(), 1.0).is_none());\n+ }\n+\n+ #[test]\n+ fn same_uuid_replaces_prior_vote_on_pair() {\n+ let mut g = GroupState::new();\n+ let uuid = \"u1\";\n+ g.apply_vote(vote(1, \"a\", \"b\", 2, 1, \"alice\"), uuid);\n+ let first_total: f64 = g.edges.values().sum();\n+ assert_eq!(first_total, 3.0);\n+\n+ g.apply_vote(vote(2, \"a\", \"b\", 0, 1, \"bob\"), uuid);\n+ let second_total: f64 = g.edges.values().sum();\n+ assert_eq!(second_total, 1.0);\n+ assert_eq!(g.uuid_votes.len(), 1);\n }\n \n #[test]\n- fn zero_weight_vote_does_not_mark_pair_or_edges() {\n+ fn different_uuids_both_count() {\n let mut g = GroupState::new();\n- g.apply_vote(VoteData {\n- ts: 1,\n- a: ItemId::opaque(\"a\"),\n- b: ItemId::opaque(\"b\"),\n- ratio_left: 0,\n- ratio_right: 0,\n- body: String::new(),\n- principal: \"test\".to_string(),\n- delegate: None,\n- thread_tag: \"untagged\".to_string(),\n- });\n- assert!(g.voted_pairs.is_empty());\n- assert!(g.edges.is_empty());\n- assert!(g.recent_votes.is_empty());\n+ g.apply_vote(vote(1, \"a\", \"b\", 2, 1, \"alice\"), \"u1\");\n+ g.apply_vote(vote(2, \"a\", \"b\", 0, 1, \"bob\"), \"u2\");\n+ let total: f64 = g.edges.values().sum();\n+ assert_eq!(total, 4.0);\n+ assert_eq!(g.uuid_votes.len(), 2);\n }\n \n #[test]\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 247b9047a57956f76c4b6bef691662101e62a8f9..8dabc93e95c39cbe18d69596dee79683b384ef86 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -9,7 +9,7 @@ use crate::{\n projection_apply,\n projection_store::ProjectionStore,\n reddit::{RedditApiConfig, RedditBroker, REDDIT_CONTENT_TTL},\n- reducer::{GlobalTree, VoteData},\n+ reducer::GlobalTree,\n view_log::ViewLog,\n views::ViewStore,\n };\n@@ -234,23 +234,31 @@ impl AppState {\n if a_raw.is_empty() || b_raw.is_empty() || a_raw == b_raw {\n return Err(\"invalid vote: need two distinct non-empty items\".to_string());\n }\n- if ratio_left.max(0) == 0 && ratio_right.max(0) == 0 {\n+ let left = ratio_left.max(0);\n+ let right = ratio_right.max(0);\n+ if left == 0 && right == 0 {\n return Err(\n \"invalid vote: need a positive preference on at least one side\".to_string(),\n );\n }\n- // Validate items canonicalize (or are opaque keys) before append.\n- let _ = VoteData::from_recorded(ts, a_raw, b_raw, ratio_left, ratio_right)\n- .ok_or_else(|| \"invalid vote: need two distinct parseable items\".to_string())?;\n+ let a_id = ItemId::from_storage(a_raw)\n+ .or_else(|| ItemId::parse(a_raw))\n+ .ok_or_else(|| \"invalid vote: unparseable item a\".to_string())?;\n+ let b_id = ItemId::from_storage(b_raw)\n+ .or_else(|| ItemId::parse(b_raw))\n+ .ok_or_else(|| \"invalid vote: unparseable item b\".to_string())?;\n+ if a_id == b_id {\n+ return Err(\"invalid vote: need two distinct items\".to_string());\n+ }\n \n- let event = Event::VoteRecorded {\n+ let event = Event::vote_recorded(\n ts,\n- a: a_raw.to_string(),\n- b: b_raw.to_string(),\n- ratio_left,\n- ratio_right,\n- scope: parent.as_str().to_string(),\n- };\n+ a_id.as_str(),\n+ b_id.as_str(),\n+ left,\n+ right,\n+ parent.as_str(),\n+ );\n \n self.journal.append(event).await\n }\n@@ -333,17 +341,7 @@ mod tests {\n id: \"https://reddit.com/r/rust\".into(),\n },\n ),\n- event_record(\n- 2,\n- Event::VoteRecorded {\n- ts: 2,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- },\n- ),\n+ event_record(2, Event::vote_recorded(2, \"alpha\", \"beta\", 2, 1, \"\")),\n ])\n .await\n .unwrap();\n@@ -430,14 +428,7 @@ mod tests {\n let log = EventLog::new(log_path.to_string_lossy().into_owned());\n log.append(&event_record(\n 1,\n- Event::VoteRecorded {\n- ts: 1,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- },\n+ Event::vote_recorded(1, \"alpha\", \"beta\", 2, 1, \"\"),\n ))\n .await\n .unwrap();\n@@ -601,14 +592,7 @@ mod tests {\n let log = EventLog::new(format!(\"{data_dir}/events.jsonl\"));\n log.append(&event_record(\n 1,\n- Event::VoteRecorded {\n- ts: 1,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- },\n+ Event::vote_recorded(1, \"alpha\", \"beta\", 2, 1, \"\"),\n ))\n .await\n .unwrap();\ndiff --git a/server/src/storage_dto.rs b/server/src/storage_dto.rs\nindex 3fd6db5cb909ac4896bd8a3ecace796de5f08781..22f5ac498ae3a5db4347f3fe94830539c7c01e3e 100644\n--- a/server/src/storage_dto.rs\n+++ b/server/src/storage_dto.rs\n@@ -1,9 +1,8 @@\n //! Versioned leaf value DTOs persisted in durable collections.\n //!\n-//! Node structure (children, edges, voted pairs, recent votes) is no longer a\n-//! single blob — it lives as point-addressable durable collections (see\n-//! [`crate::storage_schema`]). This module only defines the small leaf values:\n-//! ephemeral entity views and individual votes.\n+//! Node structure (children, uuid votes, recent votes) lives as point-addressable\n+//! durable collections (see [`crate::storage_schema`]). This module defines the\n+//! small leaf values: ephemeral entity views and individual votes.\n \n use serde::{Deserialize, Serialize};\n \n@@ -12,7 +11,7 @@ use crate::{\n reducer::{EntityData, VoteData},\n };\n \n-pub const VOTE_RECORD_VERSION: u32 = 1;\n+pub const VOTE_RECORD_VERSION: u32 = 2;\n pub const ENTITY_DATA_VERSION: u32 = 1;\n \n #[derive(Debug, Clone, Serialize, Deserialize)]\n@@ -39,7 +38,7 @@ pub struct StoredEntityDataV1 {\n pub link_url: Option,\n }\n \n-/// One vote stored in a node's `recent_votes` list.\n+/// One vote stored in a node's `recent_votes` list or `uuid_votes` map.\n #[derive(Debug, Clone, Serialize, Deserialize)]\n pub struct StoredVoteV1 {\n pub version: u32,\n@@ -48,10 +47,8 @@ pub struct StoredVoteV1 {\n pub b: String,\n pub ratio_left: i32,\n pub ratio_right: i32,\n- pub body: String,\n- pub principal: String,\n- pub delegate: Option,\n- pub thread_tag: String,\n+ pub pseudonym: String,\n+ pub trust_weight: f64,\n }\n \n pub fn encode_entity_data(data: &EntityData) -> StoredEntityDataV1 {\n@@ -85,10 +82,8 @@ pub fn encode_vote(vote: &VoteData) -> StoredVoteV1 {\n b: vote.b.as_str().to_string(),\n ratio_left: vote.ratio_left,\n ratio_right: vote.ratio_right,\n- body: vote.body.clone(),\n- principal: vote.principal.clone(),\n- delegate: vote.delegate.clone(),\n- thread_tag: vote.thread_tag.clone(),\n+ pseudonym: vote.pseudonym.clone(),\n+ trust_weight: vote.trust_weight,\n }\n }\n \n@@ -99,10 +94,8 @@ pub fn decode_vote(vote: StoredVoteV1) -> Result {\n b: parse_stored_id(&vote.b)?,\n ratio_left: vote.ratio_left,\n ratio_right: vote.ratio_right,\n- body: vote.body,\n- principal: vote.principal,\n- delegate: vote.delegate,\n- thread_tag: vote.thread_tag,\n+ pseudonym: vote.pseudonym,\n+ trust_weight: vote.trust_weight,\n })\n }\n \ndiff --git a/server/src/storage_schema.rs b/server/src/storage_schema.rs\nindex 5d2bb1d56927fb61c7c6d2d8602bd6882327f862..67c9f4ab2e1865f8da81b6735dd2a05b87e5e366 100644\n--- a/server/src/storage_schema.rs\n+++ b/server/src/storage_schema.rs\n@@ -1,14 +1,13 @@\n //! Durable schema for sorter2 — the projection laid out as point-addressable\n //! durable collections instead of one blob per node.\n //!\n-//! A vote updates a handful of keys: a few edge-weight merges, a voted-pair flag,\n-//! a recent-vote list append, and child-link set entries. The in-memory\n-//! [`crate::reducer::GroupState`] is reconstructed from these keys on read for\n-//! rank-centrality.\n+//! Votes are stored as deduped `uuid_votes` entries plus an append-only\n+//! `recent_votes` audit list. Edge weights for rank centrality are derived\n+//! from `uuid_votes` on read, not incrementally merged in RocksDB.\n \n-use std::collections::{BTreeSet, HashMap, HashSet};\n+use std::collections::{HashSet};\n \n-use durable::{Batch, Db, Durable, Leaf, List, Map, Sum};\n+use durable::{Batch, Db, Durable, Leaf, List, Map};\n \n use crate::{\n path_types::ItemId,\n@@ -19,10 +18,8 @@ use crate::{\n },\n };\n \n-/// Directed edge key `(from_id, to_id)`.\n-pub type EdgeKey = (String, String);\n-/// Unordered voted-pair key, stored canonically as `(min, max)` by string.\n-pub type PairKey = (String, String);\n+/// `(actor_uuid, min_item_id, max_item_id)` — one vote slot per human per pair.\n+pub type UuidVoteKey = (String, String, String);\n \n /// One node in the fractal tree, exploded into precisely-updatable collections.\n #[derive(Durable)]\n@@ -34,22 +31,21 @@ pub struct NodeSchema {\n pub data: Leaf,\n /// Child ids (a set; value is always `true`).\n pub children: Map>,\n- /// Directed edge weights `(from, to) -> weight`, updated by blind merges.\n- pub edges: Map>,\n- /// Voted pairs `(min, max) -> true`.\n- pub voted_pairs: Map>,\n+ /// Latest vote per actor per unordered pair; edges are derived from this on read.\n+ pub uuid_votes: Map>,\n /// Recent votes, append-only oldest-first (cap applied on read).\n pub recent_votes: List>,\n /// When ephemeral Reddit display content was last fetched (ms); absent after eviction.\n pub fetched_at: Leaf,\n }\n \n-/// The single database root: nodes, view counts, and per-concern metadata maps\n-/// (cursors and schema versions).\n+/// The single database root: nodes, identity maps, view counts, and metadata.\n #[derive(Durable)]\n #[allow(dead_code)]\n pub struct Store {\n pub nodes: Map,\n+ /// Global pseudonym → actor UUID (Sybil dedup anchor).\n+ pub pseudonyms: Map>,\n pub proj_meta: Map>,\n pub view_counts: Map>,\n pub view_meta: Map>,\n@@ -62,6 +58,21 @@ fn id_key(id: &ItemId) -> String {\n id.as_str().to_string()\n }\n \n+fn pair_keys(a: &ItemId, b: &ItemId) -> (String, String) {\n+ let ak = id_key(a);\n+ let bk = id_key(b);\n+ if ak <= bk {\n+ (ak, bk)\n+ } else {\n+ (bk, ak)\n+ }\n+}\n+\n+pub fn uuid_vote_key(actor_uuid: &str, a: &ItemId, b: &ItemId) -> UuidVoteKey {\n+ let (lo, hi) = pair_keys(a, b);\n+ (actor_uuid.to_string(), lo, hi)\n+}\n+\n /// Path to a node by id.\n pub fn node(id: &ItemId) -> durable::Path {\n Store::root().nodes().key(&id_key(id))\n@@ -77,16 +88,10 @@ pub fn load_node_state(db: &Db, id: &ItemId) -> durable::Result durable::Result durable::Result,\n- voted: Vec,\n- edges_raw: Vec<(EdgeKey, f64)>,\n-) -> durable::Result {\n- // Item universe = every endpoint that appears in a voted pair or an edge.\n- let mut item_strs: BTreeSet = BTreeSet::new();\n- for (a, b) in &voted {\n- item_strs.insert(a.clone());\n- item_strs.insert(b.clone());\n- }\n- for ((a, b), _) in &edges_raw {\n- item_strs.insert(a.clone());\n- item_strs.insert(b.clone());\n- }\n-\n- let mut idx_to_item: Vec = Vec::with_capacity(item_strs.len());\n- let mut item_to_idx: HashMap = HashMap::with_capacity(item_strs.len());\n- let mut str_to_idx: HashMap = HashMap::with_capacity(item_strs.len());\n- for s in item_strs {\n- let id = parse_storage_id(&s)?;\n- let idx = idx_to_item.len();\n- str_to_idx.insert(s, idx);\n- item_to_idx.insert(id.clone(), idx);\n- idx_to_item.push(id);\n- }\n+fn build_group_state(db: &Db, np: &durable::Path) -> durable::Result {\n+ let mut group = GroupState::new();\n \n- let mut edges: HashMap<(usize, usize), f64> = HashMap::with_capacity(edges_raw.len());\n- for ((a, b), w) in edges_raw {\n- if let (Some(&ai), Some(&bi)) = (str_to_idx.get(&a), str_to_idx.get(&b)) {\n- edges.insert((ai, bi), w);\n- }\n- }\n-\n- let mut voted_pairs: HashSet<(usize, usize)> = HashSet::with_capacity(voted.len());\n- for (a, b) in voted {\n- if let (Some(&ai), Some(&bi)) = (str_to_idx.get(&a), str_to_idx.get(&b)) {\n- let (i, j) = if ai < bi { (ai, bi) } else { (bi, ai) };\n- voted_pairs.insert((i, j));\n- }\n+ for (key, stored) in np.uuid_votes().iter(db)? {\n+ let (actor_uuid, _lo, _hi) = key;\n+ let vote = decode_vote(stored).map_err(durable::Error::Deserialize)?;\n+ group.ingest_uuid_vote(vote, &actor_uuid);\n }\n \n- // List is index order (oldest first); keep the newest RECENT_VOTES_CAP entries.\n let stored = np.recent_votes().iter(db)?;\n let cap = RECENT_VOTES_CAP as usize;\n let start = stored.len().saturating_sub(cap);\n- let recent_votes = stored[start..]\n+ group.recent_votes = stored[start..]\n .iter()\n .map(|s| decode_vote(s.clone()).map_err(durable::Error::Deserialize))\n .collect::, _>>()?;\n \n- Ok(GroupState {\n- item_to_idx,\n- idx_to_item,\n- edges,\n- voted_pairs,\n- recent_votes,\n- })\n+ Ok(group)\n }\n \n fn parse_storage_id(s: &str) -> durable::Result {\n@@ -197,73 +161,24 @@ pub fn ensure_path_writes(batch: &mut Batch, id: &ItemId) {\n }\n }\n \n-/// Reified writes for a recorded vote under `parent`. Mirrors\n-/// [`crate::reducer::GroupState::apply_vote`] as point updates.\n+/// Reified writes for a validated vote under `parent`.\n pub fn vote_writes(\n batch: &mut Batch,\n parent: &ItemId,\n- a: &str,\n- b: &str,\n- ratio_left: i32,\n- ratio_right: i32,\n- ts: i64,\n+ vote: &VoteData,\n+ actor_uuid: &str,\n ) -> durable::Result<()> {\n- let left = ratio_left.max(0);\n- let right = ratio_right.max(0);\n- if left == 0 && right == 0 {\n- return Ok(());\n- }\n-\n- let a_id = ItemId::from_storage(a).unwrap_or_else(|| ItemId::opaque(a));\n- let b_id = ItemId::from_storage(b).unwrap_or_else(|| ItemId::opaque(b));\n-\n ensure_path_writes(batch, parent);\n- ensure_path_writes(batch, &a_id);\n- ensure_path_writes(batch, &b_id);\n+ ensure_path_writes(batch, &vote.a);\n+ ensure_path_writes(batch, &vote.b);\n \n let pnode = node(parent);\n- batch.write(pnode.children().key(&id_key(&a_id)).set(&true));\n- batch.write(pnode.children().key(&id_key(&b_id)).set(&true));\n-\n- // Edge weights: edge (b,a) += left, edge (a,b) += right (positive only).\n- if left > 0 {\n- batch.write(\n- pnode\n- .edges()\n- .key(&(id_key(&b_id), id_key(&a_id)))\n- .add(left as f64),\n- );\n- }\n- if right > 0 {\n- batch.write(\n- pnode\n- .edges()\n- .key(&(id_key(&a_id), id_key(&b_id)))\n- .add(right as f64),\n- );\n- }\n+ batch.write(pnode.children().key(&id_key(&vote.a)).set(&true));\n+ batch.write(pnode.children().key(&id_key(&vote.b)).set(&true));\n \n- // Voted pair, canonicalized.\n- let (lo, hi) = if id_key(&a_id) <= id_key(&b_id) {\n- (id_key(&a_id), id_key(&b_id))\n- } else {\n- (id_key(&b_id), id_key(&a_id))\n- };\n- batch.write(pnode.voted_pairs().key(&(lo, hi)).set(&true));\n-\n- // Recent votes (append-only; cap on read).\n- let stored = encode_vote(&VoteData {\n- ts,\n- a: a_id,\n- b: b_id,\n- ratio_left: left,\n- ratio_right: right,\n- body: String::new(),\n- principal: \"web\".to_string(),\n- delegate: None,\n- thread_tag: \"default\".to_string(),\n- });\n- batch.push(&pnode.recent_votes(), &stored)?;\n+ let key = uuid_vote_key(actor_uuid, &vote.a, &vote.b);\n+ batch.write(pnode.uuid_votes().key(&key).set(&encode_vote(vote)));\n+ batch.push(&pnode.recent_votes(), &encode_vote(vote))?;\n Ok(())\n }\n \n@@ -283,15 +198,30 @@ pub fn entity_content_clear_writes(batch: &mut Batch, id: &ItemId) {\n #[cfg(test)]\n mod tests {\n use super::*;\n+ use crate::identity::{seed_default_pseudonym, DEFAULT_ACTOR_UUID, DEFAULT_PSEUDONYM};\n+\n+ fn sample_vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n+ VoteData {\n+ ts,\n+ a: ItemId::opaque(a),\n+ b: ItemId::opaque(b),\n+ ratio_left: l,\n+ ratio_right: r,\n+ pseudonym: DEFAULT_PSEUDONYM.to_string(),\n+ trust_weight: 1.0,\n+ }\n+ }\n \n #[test]\n fn vote_roundtrip_reconstructs_group_state() {\n let dir = tempfile::tempdir().unwrap();\n let db = Db::open(dir.path()).unwrap();\n+ seed_default_pseudonym(&db).unwrap();\n let parent = ItemId::root();\n \n+ let vote = sample_vote(1, \"alpha\", \"beta\", 2, 1);\n let mut batch = db.batch();\n- vote_writes(&mut batch, &parent, \"alpha\", \"beta\", 2, 1, 1).unwrap();\n+ vote_writes(&mut batch, &parent, &vote, DEFAULT_ACTOR_UUID).unwrap();\n batch.commit().unwrap();\n \n let node_state = load_node_state(&db, &parent).unwrap().unwrap();\n@@ -305,16 +235,38 @@ mod tests {\n }\n \n #[test]\n- fn zero_weight_vote_writes_nothing() {\n+ fn uuid_vote_replace_updates_edges() {\n let dir = tempfile::tempdir().unwrap();\n let db = Db::open(dir.path()).unwrap();\n let parent = ItemId::root();\n+ let uuid = \"u1\";\n \n let mut batch = db.batch();\n- vote_writes(&mut batch, &parent, \"alpha\", \"beta\", 0, 0, 1).unwrap();\n+ vote_writes(\n+ &mut batch,\n+ &parent,\n+ &sample_vote(1, \"alpha\", \"beta\", 2, 1),\n+ uuid,\n+ )\n+ .unwrap();\n+ vote_writes(\n+ &mut batch,\n+ &parent,\n+ &VoteData {\n+ pseudonym: \"alias2\".into(),\n+ ratio_left: 0,\n+ ratio_right: 1,\n+ ..sample_vote(2, \"alpha\", \"beta\", 0, 1)\n+ },\n+ uuid,\n+ )\n+ .unwrap();\n batch.commit().unwrap();\n \n- assert!(load_node_state(&db, &parent).unwrap().is_none());\n+ let g = &load_node_state(&db, &parent).unwrap().unwrap().local_ranking;\n+ let edge_total: f64 = g.edges.values().sum();\n+ assert_eq!(edge_total, 1.0);\n+ assert_eq!(g.uuid_votes.len(), 1);\n }\n \n #[test]\n@@ -325,7 +277,13 @@ mod tests {\n \n let mut batch = db.batch();\n for i in 0..RECENT_VOTES_CAP + 10 {\n- vote_writes(&mut batch, &parent, \"alpha\", \"beta\", 1, 0, i as i64).unwrap();\n+ vote_writes(\n+ &mut batch,\n+ &parent,\n+ &sample_vote(i as i64, \"alpha\", \"beta\", 1, 0),\n+ \"u1\",\n+ )\n+ .unwrap();\n }\n batch.commit().unwrap();\n \n@@ -337,13 +295,13 @@ mod tests {\n let node_state = load_node_state(&db, &parent).unwrap().unwrap();\n assert_eq!(node_state.local_ranking.recent_votes.len(), RECENT_VOTES_CAP as usize);\n assert_eq!(\n- node_state.local_ranking.recent_votes.first().map(|v| v.ts),\n+ node_state\n+ .local_ranking\n+ .recent_votes\n+ .first()\n+ .map(|v| v.ts),\n Some(10)\n );\n- assert_eq!(\n- node_state.local_ranking.recent_votes.last().map(|v| v.ts),\n- Some(RECENT_VOTES_CAP as i64 + 9)\n- );\n }\n \n #[test]\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[e8c85249] Fix GitHub resolver and vote compare flow (#149)\n\n* Fix GitHub resolver and vote compare flow\n\nCo-authored-by: tommy \n\n* Fix vote compare next pair helper\n\nCo-authored-by: tommy \n\n* Extend GitHub resolver and vote pair updates\n\nCo-authored-by: tommy \n\n* Fix resolver browser refresh coverage\n\nCo-authored-by: tommy \n\n* Stabilize GitHub resolver browser test\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide B — unified diff (full patch):\ndiff --git a/agents.md b/agents.md\nindex c66f33789441eea193b4354fce4c03b7fffdd639..7508234d9b04223d0e64cfe69fedbebd06a256b5 100644\n--- a/agents.md\n+++ b/agents.md\n@@ -36,12 +36,18 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma\n \n - **`HtmlUiAction` / `POST /ui`** (`server/src/html/ui_action.rs`, `server/src/api/ui_html.rs`): **Browser session** (cookie) UI commands. Payload is `__rpc__` + form fields. Most responses are **JS morphs**; some actions return **HTTP redirects** (see below).\n \n+- **Do not add one-off POST routes** for browser mutations. New browser actions belong in **`HtmlUiAction`** behind **`POST /ui`**; new programmatic verbs belong in **`RpcCommand`** behind **`POST /api/v0/rpc`**. Ordinary shareable pages remain normal **`GET`** routes.\n+\n - **Non-morph `POST /ui` responses:** **`SetGardenPin`** returns **`303 See Other`** and **`Set-Cookie`** (same as **`POST /theme`**). Garden pin/unpin is a normal **`
`** — browser navigation applies cookies reliably (see **`test/browser_garden_pin.clj`**). Each **`__rpc__`** payload includes **`form_action: \"/ui\"`**; **`post_ui_html`** rejects mismatches to bind tokens to the UI endpoint.\n \n-- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card), **`#vote-edge-history-region`** (recomputed **`
    `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: \"/ui\"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.\n+- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
      `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: \"/ui\"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.\n+\n+- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only.\n \n - **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.\n \n+- **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection.\n+\n **Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate.\n \n ---\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex c9c4082f251838b5ac46de7ce48392c136883d55..88bb3335e4873643530351266d213f258da5893e 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -8,7 +8,10 @@ use axum::{\n use axum_extra::extract::cookie::CookieJar;\n use base64::Engine;\n use serde::Deserialize;\n-use slug_types::{PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, WhoamiResponse};\n+use slug_types::{\n+ PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse,\n+ WhoamiResponse,\n+};\n use std::{collections::HashMap, sync::Arc};\n use tokio::sync::{oneshot, RwLock};\n \n@@ -17,7 +20,8 @@ use crate::{\n events::{Event, TokenIssued},\n html::{\n auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,\n- choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder,\n+ choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri,\n+ JsBuilder,\n },\n identity::{parse_agent, parse_username},\n reducer::ReducerState,\n@@ -36,12 +40,26 @@ pub const SLUG_SESSION_COOKIE: &str = \"slug_session\";\n \n /// `Set-Cookie` header value (full attribute string).\n pub fn session_cookie_header_value(bearer: &str) -> HeaderValue {\n- let s = format!(\n- \"{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000\"\n- );\n+ let s =\n+ format!(\"{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000\");\n HeaderValue::from_str(&s).expect(\"session cookie value must be ASCII\")\n }\n \n+fn safe_local_redirect(raw: Option<&str>) -> Option {\n+ let s = raw?.trim();\n+ if s.starts_with('/') && !s.starts_with(\"//\") && s.len() < 8192 {\n+ Some(s.to_string())\n+ } else {\n+ None\n+ }\n+}\n+\n+fn redirect_query(next: Option<&str>) -> String {\n+ safe_local_redirect(next)\n+ .map(|n| format!(\"&next={}\", urlencoding::encode(&n)))\n+ .unwrap_or_default()\n+}\n+\n fn js_form_error_fragment(session: &str, error: &str) -> Response {\n JsBuilder::new()\n .id(\"choose-username-form\")\n@@ -49,11 +67,11 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response {\n .into_response()\n }\n \n-fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {\n+fn js_signed_in_fragment(bearer: &str, jar: &CookieJar, redirect_to: &str) -> Response {\n let mut response = JsBuilder::new()\n .id(\"choose-username-form\")\n .morph_inner(auth_signed_in_fragment())\n- .redirect(\"/auth/complete\")\n+ .redirect(redirect_to)\n .into_response();\n let headers = response.headers_mut();\n headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));\n@@ -64,7 +82,11 @@ fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {\n }\n \n /// Resolve the signed-in username from `Authorization: Bearer` or `slug_session` cookie.\n-pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option {\n+pub fn optional_principal(\n+ headers: &HeaderMap,\n+ jar: &CookieJar,\n+ reduced: &ReducerState,\n+) -> Option {\n if let Ok(u) = verify_bearer_principal(headers, reduced) {\n return Some(u);\n }\n@@ -80,7 +102,11 @@ pub struct WebSession {\n }\n \n /// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).\n-pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option {\n+pub fn resolve_web_session(\n+ headers: &HeaderMap,\n+ jar: &CookieJar,\n+ reduced: &ReducerState,\n+) -> Option {\n let username = optional_principal(headers, jar, reduced)?;\n let bearer = headers\n .get(header::AUTHORIZATION)\n@@ -90,7 +116,12 @@ pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduc\n Some(WebSession { username, bearer })\n }\n \n-fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {\n+fn redirect_with_session_cookie(\n+ public_url: &str,\n+ path_and_query: &str,\n+ bearer: &str,\n+ jar: &CookieJar,\n+) -> Response {\n let mut res = Response::builder()\n .status(StatusCode::TEMPORARY_REDIRECT)\n .header(header::LOCATION, format!(\"{public_url}{path_and_query}\"))\n@@ -112,21 +143,32 @@ fn pending_sessions(state: &AppState) -> Arc Option {\n let payload_b64 = jwt.split('.').nth(1)?;\n- let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64).ok()?;\n+ let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD\n+ .decode(payload_b64)\n+ .ok()?;\n let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?;\n v.get(\"sub\")?.as_str().map(|s| s.to_string())\n }\n \n pub(crate) fn parse_bearer(headers: &HeaderMap) -> Result {\n let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {\n- return Err((StatusCode::UNAUTHORIZED, \"missing Authorization header\".to_string()));\n+ return Err((\n+ StatusCode::UNAUTHORIZED,\n+ \"missing Authorization header\".to_string(),\n+ ));\n };\n let Ok(s) = value.to_str() else {\n- return Err((StatusCode::UNAUTHORIZED, \"invalid Authorization header\".to_string()));\n+ return Err((\n+ StatusCode::UNAUTHORIZED,\n+ \"invalid Authorization header\".to_string(),\n+ ));\n };\n let s = s.trim();\n let Some(rest) = s.strip_prefix(\"Bearer \") else {\n- return Err((StatusCode::UNAUTHORIZED, \"Authorization must be Bearer\".to_string()));\n+ return Err((\n+ StatusCode::UNAUTHORIZED,\n+ \"Authorization must be Bearer\".to_string(),\n+ ));\n };\n Ok(rest.trim().to_string())\n }\n@@ -140,7 +182,10 @@ pub fn verify_bearer_principal(\n verify_token(reduced, &bearer)\n }\n \n-pub(crate) fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result {\n+pub(crate) fn verify_token(\n+ reduced: &crate::reducer::ReducerState,\n+ bearer: &str,\n+) -> Result {\n // slug__\n let Some(rest) = bearer.strip_prefix(\"slug_\") else {\n return Err((StatusCode::UNAUTHORIZED, \"invalid token format\".to_string()));\n@@ -199,9 +244,25 @@ pub(crate) fn issue_token_for_user(stored_username: &str) -> (String, TokenIssue\n #[derive(Debug, Deserialize)]\n pub struct AuthLoginQuery {\n pub session: String,\n+ #[serde(default)]\n+ pub next: Option,\n+ #[serde(default)]\n+ pub redirect: Option,\n }\n \n-pub async fn get_join_invite(Path(token): Path, State(state): State) -> impl IntoResponse {\n+#[derive(Debug, Deserialize)]\n+pub struct JoinInviteQuery {\n+ #[serde(default)]\n+ pub next: Option,\n+ #[serde(default)]\n+ pub redirect: Option,\n+}\n+\n+pub async fn get_join_invite(\n+ Path(token): Path,\n+ Query(q): Query,\n+ State(state): State,\n+) -> impl IntoResponse {\n let token = token.trim().to_string();\n if token.is_empty() {\n return api_error(StatusCode::NOT_FOUND, \"invite invalid or expired\", None).into_response();\n@@ -219,37 +280,57 @@ pub async fn get_join_invite(Path(token): Path, State(state): State, State(state): State) -> impl IntoResponse {\n+pub async fn get_auth_login(\n+ Query(q): Query,\n+ State(state): State,\n+) -> impl IntoResponse {\n // Redirect to Google auth endpoint.\n let sessions = pending_sessions(&state);\n- let sessions_read = sessions.read().await;\n- let Some(_s) = sessions_read.get(&q.session) else {\n- return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n- };\n- drop(sessions_read);\n+ {\n+ let mut sessions_write = sessions.write().await;\n+ let Some(s) = sessions_write.get_mut(&q.session) else {\n+ return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n+ };\n+ if let Some(next) = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) {\n+ s.redirect_next = Some(next);\n+ }\n+ }\n \n let auth_url_base = std::env::var(\"SLUG_GOOGLE_AUTH_URL\")\n .unwrap_or_else(|_| \"https://accounts.google.com/o/oauth2/v2/auth\".to_string());\n let client_id = std::env::var(\"SLUG_GOOGLE_CLIENT_ID\").unwrap_or_else(|_| \"dev\".to_string());\n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ let public_url =\n+ std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n let redirect_uri = format!(\"{public_url}/auth/callback\");\n let auth_url = format!(\n \"{auth_url_base}?client_id={}&redirect_uri={}&response_type=code&scope=openid%20email&state={}\",\n@@ -282,8 +363,10 @@ pub async fn get_auth_callback(\n let token_url = std::env::var(\"SLUG_GOOGLE_TOKEN_URL\")\n .unwrap_or_else(|_| \"https://oauth2.googleapis.com/token\".to_string());\n let client_id = std::env::var(\"SLUG_GOOGLE_CLIENT_ID\").unwrap_or_else(|_| \"dev\".to_string());\n- let client_secret = std::env::var(\"SLUG_GOOGLE_CLIENT_SECRET\").unwrap_or_else(|_| \"dev\".to_string());\n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ let client_secret =\n+ std::env::var(\"SLUG_GOOGLE_CLIENT_SECRET\").unwrap_or_else(|_| \"dev\".to_string());\n+ let public_url =\n+ std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n let redirect_uri = format!(\"{public_url}/auth/callback\");\n \n // Exchange code for id_token + access_token.\n@@ -306,16 +389,37 @@ pub async fn get_auth_callback(\n {\n Ok(resp) => match resp.json().await {\n Ok(v) => v,\n- Err(err) => return api_error(StatusCode::BAD_GATEWAY, \"oauth token exchange failed\", Some(format!(\"{err}\"))).into_response(),\n+ Err(err) => {\n+ return api_error(\n+ StatusCode::BAD_GATEWAY,\n+ \"oauth token exchange failed\",\n+ Some(format!(\"{err}\")),\n+ )\n+ .into_response()\n+ }\n },\n- Err(err) => return api_error(StatusCode::BAD_GATEWAY, \"oauth token exchange failed\", Some(format!(\"{err}\"))).into_response(),\n+ Err(err) => {\n+ return api_error(\n+ StatusCode::BAD_GATEWAY,\n+ \"oauth token exchange failed\",\n+ Some(format!(\"{err}\")),\n+ )\n+ .into_response()\n+ }\n };\n \n // Extract sub from the id_token JWT payload (base64-decode middle segment).\n // The token arrived directly from Google over TLS — no need for an extra userinfo roundtrip.\n let sub = match extract_jwt_sub(&tr.id_token) {\n Some(s) => s,\n- None => return api_error(StatusCode::BAD_GATEWAY, \"oauth: could not extract sub from id_token\", None).into_response(),\n+ None => {\n+ return api_error(\n+ StatusCode::BAD_GATEWAY,\n+ \"oauth: could not extract sub from id_token\",\n+ None,\n+ )\n+ .into_response()\n+ }\n };\n \n // If user exists, issue token and complete session. Otherwise redirect to choose-username.\n@@ -327,7 +431,9 @@ pub async fn get_auth_callback(\n \n {\n let mut sessions_write = sessions.write().await;\n- let s = sessions_write.get_mut(&q.state).expect(\"session checked above\");\n+ let s = sessions_write\n+ .get_mut(&q.state)\n+ .expect(\"session checked above\");\n s.provider = Some(\"google\".to_string());\n s.provider_id = Some(sub.clone());\n if let Some(username) = existing {\n@@ -345,31 +451,61 @@ pub async fn get_auth_callback(\n .await\n .is_err()\n {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer unavailable\", None).into_response();\n+ return api_error(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ \"writer unavailable\",\n+ None,\n+ )\n+ .into_response();\n }\n match rx.await {\n Err(_) => {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer dropped\", None).into_response();\n+ return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer dropped\", None)\n+ .into_response();\n }\n Ok(Err(err)) => {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"failed to persist token\", Some(err))\n- .into_response();\n+ return api_error(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ \"failed to persist token\",\n+ Some(err),\n+ )\n+ .into_response();\n }\n Ok(Ok(())) => {}\n }\n+ let redirect_to =\n+ safe_local_redirect(s.redirect_next.as_deref()).unwrap_or_else(|| \"/\".to_string());\n let cookie_bearer = bearer.clone();\n s.complete = Some((username, bearer));\n- return redirect_with_session_cookie(&public_url, \"/\", &cookie_bearer, &jar).into_response();\n+ return redirect_with_session_cookie(&public_url, &redirect_to, &cookie_bearer, &jar)\n+ .into_response();\n }\n }\n \n- Redirect::temporary(&format!(\"{public_url}/auth/choose-username?session={}\", q.state)).into_response()\n+ let next_q = {\n+ let sessions_read = sessions.read().await;\n+ sessions_read\n+ .get(&q.state)\n+ .and_then(|s| s.redirect_next.as_deref())\n+ .map(|n| redirect_query(Some(n)))\n+ .unwrap_or_default()\n+ };\n+ Redirect::temporary(&format!(\n+ \"{public_url}/auth/choose-username?session={}{}\",\n+ urlencoding::encode(&q.state),\n+ next_q\n+ ))\n+ .into_response()\n }\n \n #[derive(Debug, Deserialize)]\n pub struct ChooseUsernameQuery {\n pub session: String,\n pub error: Option,\n+ #[serde(default)]\n+ pub next: Option,\n+ #[serde(default)]\n+ pub redirect: Option,\n }\n \n pub async fn get_choose_username(\n@@ -379,13 +515,18 @@ pub async fn get_choose_username(\n uri: Uri,\n ) -> impl IntoResponse {\n let sessions = pending_sessions(&state);\n- let sessions_read = sessions.read().await;\n- if !sessions_read.contains_key(&q.session) {\n- return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n+ {\n+ let mut sessions_write = sessions.write().await;\n+ let Some(s) = sessions_write.get_mut(&q.session) else {\n+ return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n+ };\n+ if let Some(next) = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) {\n+ s.redirect_next = Some(next);\n+ }\n }\n- drop(sessions_read);\n let next = theme_next_from_uri(&uri);\n- choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next).into_response()\n+ choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next)\n+ .into_response()\n }\n \n #[derive(Debug, Deserialize)]\n@@ -401,7 +542,10 @@ pub async fn post_choose_username(\n ) -> impl IntoResponse {\n let canon_user = match parse_username(&form.username) {\n Ok(u) => u,\n- Err(msg) => return js_form_error_fragment(&form.session, &format!(\"invalid username — {msg}\")).into_response(),\n+ Err(msg) => {\n+ return js_form_error_fragment(&form.session, &format!(\"invalid username — {msg}\"))\n+ .into_response()\n+ }\n };\n \n let sessions = pending_sessions(&state);\n@@ -420,12 +564,15 @@ pub async fn post_choose_username(\n };\n \n if let Err(msg) = parse_agent(&agent) {\n- return js_form_error_fragment(&form.session, &format!(\"invalid agent format — {msg}\")).into_response();\n+ return js_form_error_fragment(&form.session, &format!(\"invalid agent format — {msg}\"))\n+ .into_response();\n }\n \n let redeem_invite = {\n let sessions_read = sessions.read().await;\n- sessions_read.get(&form.session).and_then(|s| s.redeem_invite.clone())\n+ sessions_read\n+ .get(&form.session)\n+ .and_then(|s| s.redeem_invite.clone())\n };\n \n let (tx, rx) = oneshot::channel();\n@@ -441,12 +588,18 @@ pub async fn post_choose_username(\n .await\n .is_err()\n {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer unavailable\", None).into_response();\n+ return api_error(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ \"writer unavailable\",\n+ None,\n+ )\n+ .into_response();\n }\n \n let bearer = match rx.await {\n Err(_) => {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer dropped\", None).into_response();\n+ return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer dropped\", None)\n+ .into_response();\n }\n Ok(Err(msg)) => {\n return js_form_error_fragment(&form.session, &msg).into_response();\n@@ -454,31 +607,59 @@ pub async fn post_choose_username(\n Ok(Ok(b)) => b,\n };\n \n- {\n+ let redirect_to = {\n let mut sessions_write = sessions.write().await;\n- let s = sessions_write.get_mut(&form.session).expect(\"session checked above\");\n+ let s = sessions_write\n+ .get_mut(&form.session)\n+ .expect(\"session checked above\");\n s.complete = Some((canon_user.clone(), bearer.clone()));\n- }\n+ safe_local_redirect(s.redirect_next.as_deref())\n+ .unwrap_or_else(|| \"/auth/complete\".to_string())\n+ };\n \n- js_signed_in_fragment(&bearer, &jar).into_response()\n+ js_signed_in_fragment(&bearer, &jar, &redirect_to).into_response()\n }\n \n /// Start a browser-only OAuth flow (no CLI polling). Sets session cookie on success.\n-pub async fn get_web_login(State(state): State) -> impl IntoResponse {\n+#[derive(Debug, Deserialize)]\n+pub struct WebLoginQuery {\n+ #[serde(default)]\n+ pub next: Option,\n+ #[serde(default)]\n+ pub redirect: Option,\n+}\n+\n+pub async fn get_web_login(\n+ Query(q): Query,\n+ State(state): State,\n+) -> impl IntoResponse {\n let session = format!(\"p_{}\", uuid::Uuid::new_v4().simple());\n+ let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()))\n+ .or_else(|| Some(\"/\".to_string()));\n let s = PendingSession {\n agent: WEB_BROWSER_AGENT.to_string(),\n created_ts: now_ms(),\n provider: None,\n provider_id: None,\n redeem_invite: None,\n+ redirect_next: redirect_next.clone(),\n complete: None,\n };\n- state.pending_sessions.write().await.insert(session.clone(), s);\n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ state\n+ .pending_sessions\n+ .write()\n+ .await\n+ .insert(session.clone(), s);\n+ let public_url =\n+ std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ let next_q = redirect_next\n+ .as_deref()\n+ .map(|n| redirect_query(Some(n)))\n+ .unwrap_or_default();\n Redirect::temporary(&format!(\n- \"{public_url}/auth/login?session={}\",\n- urlencoding::encode(&session)\n+ \"{public_url}/auth/login?session={}{}\",\n+ urlencoding::encode(&session),\n+ next_q\n ))\n .into_response()\n }\n@@ -504,12 +685,17 @@ pub async fn post_pending_session(\n let agent_naked = match parse_agent(&req.agent) {\n Ok(a) => a,\n Err(msg) => {\n- return api_error(StatusCode::BAD_REQUEST, \"invalid agent format\", Some(msg)).into_response();\n+ return api_error(StatusCode::BAD_REQUEST, \"invalid agent format\", Some(msg))\n+ .into_response();\n }\n };\n let session = format!(\"p_{}\", uuid::Uuid::new_v4().simple());\n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n- let login_url = format!(\"{public_url}/auth/login?session={}\", urlencoding::encode(&session));\n+ let public_url =\n+ std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ let login_url = format!(\n+ \"{public_url}/auth/login?session={}\",\n+ urlencoding::encode(&session)\n+ );\n let poll_url = format!(\"/api/v0/pending-session/{}\", session);\n let s = PendingSession {\n agent: agent_naked,\n@@ -517,6 +703,7 @@ pub async fn post_pending_session(\n provider: None,\n provider_id: None,\n redeem_invite: None,\n+ redirect_next: None,\n complete: None,\n };\n let sessions = pending_sessions(&state);\n@@ -567,11 +754,14 @@ pub async fn get_whoami(State(state): State, headers: HeaderMap) -> im\n Ok(u) => u,\n Err((st, msg)) => return api_error(st, msg, None).into_response(),\n };\n- let agents_bound = reduced.agent_bindings.values().filter(|u| *u == &username).count();\n+ let agents_bound = reduced\n+ .agent_bindings\n+ .values()\n+ .filter(|u| *u == &username)\n+ .count();\n Json(WhoamiResponse {\n user: username,\n agents_bound,\n })\n .into_response()\n }\n-\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 336d54bfad5037e9d87437400747260d160bee2a..146792b9ec33d539ce8c6b106768162805dbb3f9 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -20,6 +20,7 @@ use crate::{\n path_types::ItemId,\n ranking::{connected_components_from_voted_pairs, ranked_items_subset},\n reducer::{scope_from_room_wire, ReducerState, ScopeId},\n+ scope_rank::suggest_next_pair_in_pool,\n state::{AppState, InviteState},\n write_cmd::WriteCmd,\n };\n@@ -759,7 +760,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n }\n }\n }\n- pick.or_else(|| pick_random_distinct_item_pair(&pool))\n+ pick.or_else(|| suggest_next_pair_in_pool(group, &pool, None))\n+ .or_else(|| pick_random_distinct_item_pair(&pool))\n }\n };\n let Some((left, right)) = selected else {\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex cd501ba6d4d656eabad03afed4583efdf885695d..4b0214d18b173cd506d09176104f461dc4c4f208 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -21,11 +21,11 @@ use crate::{\n external_resolver::resolve_github_children,\n html::vote_compare_post_success_js,\n html::{\n- fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form,\n- room_members_section_markup, thread_feed_html, thread_feed_html_for_room,\n- thread_feed_region_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full,\n- thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room,\n- HtmlUiAction, JsBuilder, ThreadNav,\n+ external_resolver_status_markup, fragment_new_thread_slot, login_to_post_hint_markup,\n+ parse_html_ui_from_form, room_members_section_markup, thread_feed_html,\n+ thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post,\n+ thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room,\n+ user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav,\n },\n reducer::{scope_from_room_wire, ScopeId},\n state::AppState,\n@@ -68,6 +68,13 @@ fn sanitize_garden_pin_next(next: &str) -> String {\n }\n }\n \n+fn login_redirect_for(next: &str) -> String {\n+ format!(\n+ \"/login?next={}\",\n+ urlencoding::encode(&sanitize_garden_pin_next(next))\n+ )\n+}\n+\n fn redirect_with_pin_cookie(cookie_header_value: &str, location: &str) -> Response {\n let loc = HeaderValue::try_from(location).unwrap_or_else(|_| HeaderValue::from_static(\"/\"));\n let hv = HeaderValue::try_from(cookie_header_value).expect(\"set-cookie header\");\n@@ -194,7 +201,7 @@ async fn dispatch_ui_action(\n .into_response();\n }\n let Some(session) = session else {\n- return js_redirect(\"/login\").into_response();\n+ return js_redirect(&login_redirect_for(&next)).into_response();\n };\n let err_tgt = Some(\"vote-compare-errors\".to_string());\n let room = room.trim().to_string();\n@@ -363,7 +370,7 @@ async fn dispatch_ui_action(\n .into_response();\n }\n let Some(session) = session else {\n- return js_redirect(\"/login\").into_response();\n+ return js_redirect(&login_redirect_for(&next)).into_response();\n };\n let room = room_wire.trim();\n if room.is_empty() {\n@@ -390,8 +397,19 @@ async fn dispatch_ui_action(\n item.normalized_storage()\n };\n match resolve_github_children(state, room, &target).await {\n- Ok(_) => js_redirect(&sanitize_garden_pin_next(&next)).into_response(),\n- Err(msg) => ui_js_warn(&msg).into_response(),\n+ Ok(n) => JsBuilder::new()\n+ .morph_inner_selector(\n+ \"#external-resolver-status\",\n+ external_resolver_status_markup(Ok(n), &sanitize_garden_pin_next(&next)),\n+ )\n+ .redirect(&sanitize_garden_pin_next(&next))\n+ .into_response(),\n+ Err(msg) => JsBuilder::new()\n+ .morph_inner_selector(\n+ \"#external-resolver-status\",\n+ external_resolver_status_markup(Err(msg.as_str()), &next),\n+ )\n+ .into_response(),\n }\n }\n HtmlUiAction::RedactPost { post_id } => {\ndiff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs\nindex 04bc3de32b6df1aefe197d942db67de2cbdbde4f..a5812250fed7613950b5417f396f886a55fafccf 100644\n--- a/server/src/external_resolver.rs\n+++ b/server/src/external_resolver.rs\n@@ -6,6 +6,7 @@ use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd};\n \n const GITHUB_SYSTEM_PRINCIPAL: &str = \"system:github-resolver\";\n const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000;\n+const GITHUB_MAX_PAGES: usize = 3;\n \n fn now_ms() -> i64 {\n use std::time::{SystemTime, UNIX_EPOCH};\n@@ -69,6 +70,10 @@ impl GitHubResolver {\n [owner, repo] => Ok(github_repo_sections(owner, repo)),\n [owner, repo, section] if section == \"issues\" => self.list_issues(owner, repo).await,\n [owner, repo, section] if section == \"pulls\" => self.list_pulls(owner, repo).await,\n+ [owner, repo, section] if section == \"commits\" => self.list_commits(owner, repo).await,\n+ [owner, repo, section] if section == \"releases\" => {\n+ self.list_releases(owner, repo).await\n+ }\n _ => Ok(vec![]),\n }\n }\n@@ -95,17 +100,31 @@ impl GitHubResolver {\n .map_err(|e| format!(\"GitHub response JSON failed: {e}\"))\n }\n \n+ async fn get_json_array_pages(&self, path: &str) -> Result, String> {\n+ let sep = if path.contains('?') { '&' } else { '?' };\n+ let mut out = Vec::new();\n+ for page in 1..=GITHUB_MAX_PAGES {\n+ let value = self.get_json(&format!(\"{path}{sep}page={page}\")).await?;\n+ let arr = value\n+ .as_array()\n+ .ok_or_else(|| \"GitHub paged response was not an array\".to_string())?;\n+ let n = arr.len();\n+ out.extend(arr.iter().cloned());\n+ if n < 100 {\n+ break;\n+ }\n+ }\n+ Ok(out)\n+ }\n+\n async fn list_repos(&self, owner: &str) -> Result, String> {\n- let value = self\n- .get_json(&format!(\n+ let arr = self\n+ .get_json_array_pages(&format!(\n \"/users/{owner}/repos?per_page=100&sort=updated&type=owner\"\n ))\n .await?;\n- let arr = value\n- .as_array()\n- .ok_or_else(|| \"GitHub repos response was not an array\".to_string())?;\n let mut out = Vec::new();\n- for repo in arr {\n+ for repo in &arr {\n let name = repo\n .get(\"name\")\n .and_then(|v| v.as_str())\n@@ -121,7 +140,7 @@ impl GitHubResolver {\n out.push(ResolvedChild {\n url: format!(\"https://github.com/{full_name}\"),\n title: full_name.clone(),\n- body: Some(github_json_body(repo)),\n+ body: Some(github_repo_body(repo)),\n });\n }\n out.sort_by(|a, b| a.url.cmp(&b.url));\n@@ -129,16 +148,13 @@ impl GitHubResolver {\n }\n \n async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> {\n- let value = self\n- .get_json(&format!(\n+ let arr = self\n+ .get_json_array_pages(&format!(\n \"/repos/{owner}/{repo}/issues?state=open&per_page=100\"\n ))\n .await?;\n- let arr = value\n- .as_array()\n- .ok_or_else(|| \"GitHub issues response was not an array\".to_string())?;\n let mut out = Vec::new();\n- for issue in arr {\n+ for issue in &arr {\n if issue.get(\"pull_request\").is_some() {\n continue;\n }\n@@ -152,7 +168,7 @@ impl GitHubResolver {\n out.push(ResolvedChild {\n url: format!(\"https://github.com/{owner}/{repo}/issues/{number}\"),\n title: format!(\"#{number} {title}\"),\n- body: Some(github_json_body(issue)),\n+ body: Some(github_issue_body(issue, \"issue\")),\n });\n }\n out.sort_by(|a, b| a.url.cmp(&b.url));\n@@ -160,16 +176,13 @@ impl GitHubResolver {\n }\n \n async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> {\n- let value = self\n- .get_json(&format!(\n+ let arr = self\n+ .get_json_array_pages(&format!(\n \"/repos/{owner}/{repo}/pulls?state=open&per_page=100\"\n ))\n .await?;\n- let arr = value\n- .as_array()\n- .ok_or_else(|| \"GitHub pulls response was not an array\".to_string())?;\n let mut out = Vec::new();\n- for pull in arr {\n+ for pull in &arr {\n let Some(number) = pull.get(\"number\").and_then(|v| v.as_i64()) else {\n continue;\n };\n@@ -180,7 +193,60 @@ impl GitHubResolver {\n out.push(ResolvedChild {\n url: format!(\"https://github.com/{owner}/{repo}/pulls/{number}\"),\n title: format!(\"#{number} {title}\"),\n- body: Some(github_json_body(pull)),\n+ body: Some(github_issue_body(pull, \"pull request\")),\n+ });\n+ }\n+ out.sort_by(|a, b| a.url.cmp(&b.url));\n+ Ok(out)\n+ }\n+\n+ async fn list_commits(&self, owner: &str, repo: &str) -> Result, String> {\n+ let arr = self\n+ .get_json_array_pages(&format!(\"/repos/{owner}/{repo}/commits?per_page=100\"))\n+ .await?;\n+ let mut out = Vec::new();\n+ for commit in &arr {\n+ let Some(sha) = github_string(commit, \"sha\") else {\n+ continue;\n+ };\n+ let short = sha.chars().take(7).collect::();\n+ let title = commit\n+ .get(\"commit\")\n+ .and_then(|c| c.get(\"message\"))\n+ .and_then(|v| v.as_str())\n+ .and_then(|m| m.lines().next())\n+ .filter(|s| !s.trim().is_empty())\n+ .unwrap_or(\"commit\");\n+ let url = github_string(commit, \"html_url\")\n+ .map(|s| s.to_string())\n+ .unwrap_or_else(|| format!(\"https://github.com/{owner}/{repo}/commit/{sha}\"));\n+ out.push(ResolvedChild {\n+ url,\n+ title: format!(\"{short} {title}\"),\n+ body: Some(github_commit_body(commit)),\n+ });\n+ }\n+ out.sort_by(|a, b| a.url.cmp(&b.url));\n+ Ok(out)\n+ }\n+\n+ async fn list_releases(&self, owner: &str, repo: &str) -> Result, String> {\n+ let arr = self\n+ .get_json_array_pages(&format!(\"/repos/{owner}/{repo}/releases?per_page=100\"))\n+ .await?;\n+ let mut out = Vec::new();\n+ for release in &arr {\n+ let Some(tag) = github_string(release, \"tag_name\") else {\n+ continue;\n+ };\n+ let title = github_string(release, \"name\").unwrap_or(tag);\n+ let url = github_string(release, \"html_url\")\n+ .map(|s| s.to_string())\n+ .unwrap_or_else(|| format!(\"https://github.com/{owner}/{repo}/releases/tag/{tag}\"));\n+ out.push(ResolvedChild {\n+ url,\n+ title: title.to_string(),\n+ body: Some(github_release_body(release)),\n });\n }\n out.sort_by(|a, b| a.url.cmp(&b.url));\n@@ -240,11 +306,147 @@ fn sanitize_body(s: &str) -> String {\n .collect()\n }\n \n-fn github_json_body(value: &Value) -> String {\n- let json = serde_json::to_string_pretty(value)\n- .unwrap_or_else(|_| value.to_string())\n- .replace(\"```\", \"` ` `\");\n- format!(\"```json\\n{json}\\n```\")\n+fn github_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> {\n+ value\n+ .get(key)\n+ .and_then(|v| v.as_str())\n+ .filter(|s| !s.trim().is_empty())\n+}\n+\n+fn github_user_login(value: &Value) -> Option<&str> {\n+ value\n+ .get(\"user\")\n+ .and_then(|u| u.get(\"login\"))\n+ .and_then(|v| v.as_str())\n+ .filter(|s| !s.trim().is_empty())\n+}\n+\n+fn github_labels(value: &Value) -> Vec {\n+ value\n+ .get(\"labels\")\n+ .and_then(|v| v.as_array())\n+ .into_iter()\n+ .flat_map(|labels| labels.iter())\n+ .filter_map(|label| label.get(\"name\").and_then(|v| v.as_str()))\n+ .filter(|name| !name.trim().is_empty())\n+ .map(|name| name.to_string())\n+ .collect()\n+}\n+\n+fn github_repo_body(repo: &Value) -> String {\n+ let full_name = github_string(repo, \"full_name\")\n+ .or_else(|| github_string(repo, \"name\"))\n+ .unwrap_or(\"GitHub repository\");\n+ let mut lines = vec![full_name.to_string()];\n+ if let Some(desc) = github_string(repo, \"description\") {\n+ lines.push(String::new());\n+ lines.push(desc.to_string());\n+ }\n+ if let Some(url) = github_string(repo, \"html_url\") {\n+ lines.push(String::new());\n+ lines.push(format!(\"Source: {url}\"));\n+ }\n+ if let Some(lang) = github_string(repo, \"language\") {\n+ lines.push(format!(\"Language: {lang}\"));\n+ }\n+ lines.join(\"\\n\")\n+}\n+\n+fn github_issue_body(issue: &Value, kind: &str) -> String {\n+ let number = issue\n+ .get(\"number\")\n+ .and_then(|v| v.as_i64())\n+ .map(|n| format!(\"#{n} \"))\n+ .unwrap_or_default();\n+ let title = github_string(issue, \"title\").unwrap_or(\"Untitled\");\n+ let state = github_string(issue, \"state\").unwrap_or(\"unknown\");\n+ let mut lines = vec![format!(\"{kind} {number}{title}\")];\n+ lines.push(format!(\"State: {state}\"));\n+ if let Some(author) = github_user_login(issue) {\n+ lines.push(format!(\"Author: @{author}\"));\n+ }\n+ let labels = github_labels(issue);\n+ if !labels.is_empty() {\n+ lines.push(format!(\"Labels: {}\", labels.join(\", \")));\n+ }\n+ if let Some(url) = github_string(issue, \"html_url\") {\n+ lines.push(format!(\"Source: {url}\"));\n+ }\n+ if let Some(body) = github_string(issue, \"body\") {\n+ lines.push(String::new());\n+ lines.push(body.to_string());\n+ }\n+ lines.join(\"\\n\")\n+}\n+\n+fn github_commit_body(commit: &Value) -> String {\n+ let sha = github_string(commit, \"sha\").unwrap_or(\"unknown\");\n+ let short = sha.chars().take(7).collect::();\n+ let commit_obj = commit.get(\"commit\");\n+ let message = commit_obj\n+ .and_then(|c| c.get(\"message\"))\n+ .and_then(|v| v.as_str())\n+ .unwrap_or(\"commit\");\n+ let mut lines = vec![format!(\"commit {short}\")];\n+ if let Some(author) = commit_obj\n+ .and_then(|c| c.get(\"author\"))\n+ .and_then(|a| a.get(\"name\"))\n+ .and_then(|v| v.as_str())\n+ .filter(|s| !s.trim().is_empty())\n+ {\n+ lines.push(format!(\"Author: {author}\"));\n+ }\n+ if let Some(login) = github_user_login(commit) {\n+ lines.push(format!(\"GitHub user: @{login}\"));\n+ }\n+ if let Some(date) = commit_obj\n+ .and_then(|c| c.get(\"author\"))\n+ .and_then(|a| a.get(\"date\"))\n+ .and_then(|v| v.as_str())\n+ {\n+ lines.push(format!(\"Date: {date}\"));\n+ }\n+ if let Some(url) = github_string(commit, \"html_url\") {\n+ lines.push(format!(\"Source: {url}\"));\n+ }\n+ lines.push(String::new());\n+ lines.push(message.to_string());\n+ lines.join(\"\\n\")\n+}\n+\n+fn github_release_body(release: &Value) -> String {\n+ let tag = github_string(release, \"tag_name\").unwrap_or(\"untagged\");\n+ let title = github_string(release, \"name\").unwrap_or(tag);\n+ let mut lines = vec![format!(\"release {title}\")];\n+ lines.push(format!(\"Tag: {tag}\"));\n+ if release\n+ .get(\"draft\")\n+ .and_then(|v| v.as_bool())\n+ .unwrap_or(false)\n+ {\n+ lines.push(\"Draft: yes\".to_string());\n+ }\n+ if release\n+ .get(\"prerelease\")\n+ .and_then(|v| v.as_bool())\n+ .unwrap_or(false)\n+ {\n+ lines.push(\"Prerelease: yes\".to_string());\n+ }\n+ if let Some(author) = github_user_login(release) {\n+ lines.push(format!(\"Author: @{author}\"));\n+ }\n+ if let Some(published) = github_string(release, \"published_at\") {\n+ lines.push(format!(\"Published: {published}\"));\n+ }\n+ if let Some(url) = github_string(release, \"html_url\") {\n+ lines.push(format!(\"Source: {url}\"));\n+ }\n+ if let Some(body) = github_string(release, \"body\") {\n+ lines.push(String::new());\n+ lines.push(body.to_string());\n+ }\n+ lines.join(\"\\n\")\n }\n \n fn children_to_dsl(children: &[ResolvedChild]) -> String {\n@@ -382,4 +584,47 @@ mod tests {\n assert!(dsl.contains(\"{\\\"test\\\": true}\"));\n assert!(dsl.contains(\"```\\n}\\n\"));\n }\n+\n+ #[test]\n+ fn github_issue_body_is_readable_text_not_json_dump() {\n+ let issue = serde_json::json!({\n+ \"number\": 12,\n+ \"title\": \"Render children\",\n+ \"state\": \"open\",\n+ \"html_url\": \"https://github.com/o/r/issues/12\",\n+ \"user\": {\"login\": \"octo\"},\n+ \"labels\": [{\"name\": \"bug\"}],\n+ \"body\": \"The issue body.\"\n+ });\n+ let body = github_issue_body(&issue, \"issue\");\n+ assert!(body.contains(\"issue #12 Render children\"));\n+ assert!(body.contains(\"Author: @octo\"));\n+ assert!(body.contains(\"The issue body.\"));\n+ assert!(!body.trim_start().starts_with(\"```json\"));\n+ }\n+\n+ #[test]\n+ fn github_commit_and_release_bodies_are_readable() {\n+ let commit = serde_json::json!({\n+ \"sha\": \"abcdef123456\",\n+ \"html_url\": \"https://github.com/o/r/commit/abcdef123456\",\n+ \"author\": {\"login\": \"octo\"},\n+ \"commit\": {\n+ \"message\": \"Fix vote page\\n\\nDetails here.\",\n+ \"author\": {\"name\": \"Octo Dev\", \"date\": \"2026-05-17T00:00:00Z\"}\n+ }\n+ });\n+ let release = serde_json::json!({\n+ \"tag_name\": \"v1.2.3\",\n+ \"name\": \"Release 1.2.3\",\n+ \"html_url\": \"https://github.com/o/r/releases/tag/v1.2.3\",\n+ \"author\": {\"login\": \"octo\"},\n+ \"prerelease\": true,\n+ \"body\": \"Release notes.\"\n+ });\n+ assert!(github_commit_body(&commit).contains(\"commit abcdef1\"));\n+ assert!(github_commit_body(&commit).contains(\"Fix vote page\"));\n+ assert!(github_release_body(&release).contains(\"release Release 1.2.3\"));\n+ assert!(github_release_body(&release).contains(\"Prerelease: yes\"));\n+ }\n }\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex b28271e0316738eae245855b24aeb76486860554..9ca66e7c5860d428e95abf5df518fc1e7b4f6332 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -22,7 +22,7 @@ use crate::{\n reducer::{ContentState, ReducerState, ScopeId},\n scope_rank::{\n build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive,\n- ChildrenRankings,\n+ suggest_next_pair_in_pool, ChildrenRankings,\n },\n state::AppState,\n timeago,\n@@ -237,7 +237,7 @@ pub(crate) async fn vote_compare_post_success_js(\n state: &AppState,\n nav: &ThreadNav,\n _room_wire: &str,\n- _thread_tag: &str,\n+ thread_tag: &str,\n left: &ItemId,\n right: &ItemId,\n _post_id: &str,\n@@ -246,9 +246,13 @@ pub(crate) async fn vote_compare_post_success_js(\n let reduced = state.reduced.read().await;\n let content = content_for_garden_view(&reduced, &nav.scope());\n let edge_history = vote_edge_history_markup(content, left, right);\n+ let next_pair = suggest_next_vote_pair(content, left, right);\n+ let nav_markup =\n+ vote_compare_nav_markup(nav, next_pair.as_ref(), left, right, Some(thread_tag));\n drop(reduced);\n JsBuilder::new()\n .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n+ .morph_selector(\".vote-compare-nav\", nav_markup)\n .build()\n }\n \n@@ -288,6 +292,93 @@ fn vote_compare_href(\n }\n }\n \n+fn login_href_with_next(next: &str) -> String {\n+ let next = if next.trim().starts_with('/') && !next.trim().starts_with(\"//\") {\n+ next.trim()\n+ } else {\n+ \"/\"\n+ };\n+ format!(\"/login?next={}\", urlencoding::encode(next))\n+}\n+\n+fn vote_compare_nav_markup(\n+ nav: &ThreadNav,\n+ next_pair: Option<&(ItemId, ItemId)>,\n+ left: &ItemId,\n+ right: &ItemId,\n+ thread_override: Option<&str>,\n+) -> maud::Markup {\n+ let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None));\n+ let swap_pair_href = vote_compare_href(nav, right, left, thread_override);\n+ html! {\n+ div class=\"vote-compare-nav\" {\n+ @if let Some(href) = &next_pair_href {\n+ a class=\"vote-compare-next\" data-testid=\"vote-next-pair\" href=(href) { \"next pair\" }\n+ } @else {\n+ span class=\"vote-compare-next is-disabled\" { \"no next pair\" }\n+ }\n+ a class=\"vote-compare-next\" href=(swap_pair_href) { \"swap sides\" }\n+ }\n+ }\n+}\n+\n+fn suggest_next_vote_pair(\n+ content: &ContentState,\n+ current_left: &ItemId,\n+ current_right: &ItemId,\n+) -> Option<(ItemId, ItemId)> {\n+ let mut pool: Vec = if current_left.parent().as_ref().map(|p| p.as_str())\n+ == current_right.parent().as_ref().map(|p| p.as_str())\n+ {\n+ current_left\n+ .parent()\n+ .and_then(|parent| {\n+ content\n+ .item_children\n+ .get(&parent.normalized_storage())\n+ .cloned()\n+ })\n+ .map(|children| children.into_iter().collect())\n+ .unwrap_or_default()\n+ } else {\n+ Vec::new()\n+ };\n+ if pool.len() < 2 {\n+ pool = content.items.iter().cloned().collect();\n+ }\n+ suggest_next_pair_in_pool(\n+ &content.ranking_group,\n+ &pool,\n+ Some((current_left, current_right)),\n+ )\n+}\n+\n+fn vote_compare_item_card(\n+ nav: &ThreadNav,\n+ item: &ItemId,\n+ body: Option<&String>,\n+ side_class: &str,\n+) -> maud::Markup {\n+ html! {\n+ div class=(format!(\"vote-compare-side {side_class}\")) {\n+ a class=(format!(\"vote-compare-item {side_class}\")) href=(nav.garden_item_href(item)) {\n+ code { (item_display_path(item.as_str())) }\n+ }\n+ @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {\n+ div class=\"vote-compare-item-body\" {\n+ (render_linkified_with_embeds_in_scope(\n+ body,\n+ nav.garden_root_url(),\n+ None,\n+ ))\n+ }\n+ } @else {\n+ p class=\"muted vote-compare-item-body-empty\" { \"no body yet\" }\n+ }\n+ }\n+ }\n+}\n+\n fn ont_pin_vote_controls(\n nav: &ThreadNav,\n current_storage: &str,\n@@ -1153,7 +1244,7 @@ fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str) -> Option Option,\n+ next: &str,\n+) -> maud::Markup {\n+ let next = if next.trim().starts_with('/') && !next.trim().starts_with(\"//\") {\n+ next.trim()\n+ } else {\n+ \"/\"\n+ };\n+ html! {\n+ @match imported {\n+ Ok(n) => {\n+ p class=\"resolver-status-ok\" {\n+ @if n == 0 {\n+ \"No GitHub children found. \"\n+ } @else {\n+ (format!(\"Imported {n} GitHub item{}.\", if n == 1 { \"\" } else { \"s\" })) \" \"\n+ }\n+ a href=(next) { \"Refresh page\" }\n+ \" to render the updated ontology.\"\n+ }\n+ }\n+ Err(msg) => {\n+ p class=\"resolver-status-error\" { (msg) }\n+ }\n+ }\n+ }\n+}\n+\n async fn render_scope_view(\n state: AppState,\n browse: GardenBrowsePath,\n@@ -1468,6 +1590,9 @@ async fn vote_compare_inner(\n .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right));\n let thread_tags = vote_thread_tags_for_pair(content, &left, &right);\n let edge_history = vote_edge_history_markup(content, &left, &right);\n+ let left_body = content.item_bodies.get(&left).cloned();\n+ let right_body = content.item_bodies.get(&right).cloned();\n+ let next_pair = suggest_next_vote_pair(content, &left, &right);\n drop(reduced);\n \n let title = format!(\n@@ -1495,52 +1620,51 @@ async fn vote_compare_inner(\n .expect(\"vote compare rpc json\");\n \n let body = html! {\n- h2 { \"compare\" }\n- div class=\"vote-compare-pair\" {\n- a class=\"vote-compare-item\" href=(nav.garden_item_href(&left)) {\n- code { (item_display_path(left.as_str())) }\n+ section class=\"vote-compare-shell\" {\n+ h2 { \"compare\" }\n+ div class=\"vote-compare-pair\" {\n+ (vote_compare_item_card(&nav, &left, left_body.as_ref(), \"vote-compare-left\"))\n+ span class=\"vote-compare-vs\" { \"vs\" }\n+ (vote_compare_item_card(&nav, &right, right_body.as_ref(), \"vote-compare-right\"))\n }\n- span class=\"vote-compare-vs\" { \"vs\" }\n- a class=\"vote-compare-item\" href=(nav.garden_item_href(&right)) {\n- code { (item_display_path(right.as_str())) }\n+ (vote_compare_nav_markup(&nav, next_pair.as_ref(), &left, &right, q.thread.as_deref()))\n+ div id=\"vote-edge-history-region\" {\n+ (edge_history)\n }\n- }\n- div id=\"vote-edge-history-region\" {\n- (edge_history)\n- }\n- @if can_post {\n- form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n- div class=\"vote-thread-picker\" {\n- label class=\"vote-thread-picker-label\" { \"thread\" }\n- select id=\"vote-thread-select\" name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n- @if thread_tags.is_empty() {\n- option value=\"vote\" selected { \"#vote\" }\n- }\n- @for t in &thread_tags {\n- @if *t == auto_thread {\n- option value=(t) selected { \"#\" (t) }\n- } @else {\n- option value=(t) { \"#\" (t) }\n+ @if can_post {\n+ form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n+ div class=\"vote-thread-picker\" {\n+ label class=\"vote-thread-picker-label\" { \"thread\" }\n+ select id=\"vote-thread-select\" name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n+ @if thread_tags.is_empty() {\n+ option value=\"vote\" selected { \"#vote\" }\n+ }\n+ @for t in &thread_tags {\n+ @if *t == auto_thread {\n+ option value=(t) selected { \"#\" (t) }\n+ } @else {\n+ option value=(t) { \"#\" (t) }\n+ }\n }\n }\n }\n+ input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"50\";\n+ input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"50\";\n+ label class=\"vote-compare-slider-label\" {\n+ span id=\"vote-slider-left-label\" { (item_display_path(left.as_str())) }\n+ input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n+ aria-valuemin=\"0\" aria-valuemax=\"100\";\n+ span id=\"vote-slider-right-label\" { (item_display_path(right.as_str())) }\n+ }\n+ label class=\"vote-explain-label\" { \"reason (required)\" }\n+ textarea name=\"explanation\" id=\"vote-explain\" rows=\"5\" placeholder=\"why this split?\" required {}\n+ div id=\"vote-compare-errors\" {}\n+ p { button type=\"submit\" { \"post vote\" } }\n }\n- input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"50\";\n- input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"50\";\n- label class=\"vote-compare-slider-label\" {\n- span id=\"vote-slider-left-label\" { (item_display_path(left.as_str())) }\n- input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n- aria-valuemin=\"0\" aria-valuemax=\"100\";\n- span id=\"vote-slider-right-label\" { (item_display_path(right.as_str())) }\n- }\n- label class=\"vote-explain-label\" { \"reason (required)\" }\n- textarea name=\"explanation\" id=\"vote-explain\" rows=\"5\" placeholder=\"why this split?\" required {}\n- div id=\"vote-compare-errors\" {}\n- p { button type=\"submit\" { \"post vote\" } }\n+ } @else {\n+ p class=\"muted\" { a href=(login_href_with_next(&next_path)) { \"log in\" } \" to post this vote.\" }\n }\n- } @else {\n- p class=\"muted\" { a href=\"/login\" { \"log in\" } \" to post this vote.\" }\n }\n };\n \n@@ -1649,6 +1773,42 @@ mod tests {\n assert_eq!(edge_vote_entries_for_pair(content, &a, &b).len(), 2);\n }\n \n+ #[test]\n+ fn suggest_next_vote_pair_prefers_unvoted_sibling_pair() {\n+ use super::{content_for_garden_view, suggest_next_vote_pair};\n+ use crate::path_types::ItemId;\n+ let mut reduced = ReducerState::default();\n+ apply_ingest(\n+ &mut reduced,\n+ 1,\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n\\\n+ ~/topic {root}\\n\\\n+ ~/topic/a {alpha}\\n\\\n+ ~/topic/b {beta}\\n\\\n+ ~/topic/c {gamma}\\n\\\n+ {a beats b}\\n ~/topic/a 2:1 ~/topic/b\\n\",\n+ );\n+ let content = content_for_garden_view(&reduced, &ScopeId::Public);\n+ let a = ItemId::parse(\"~/topic/a\").unwrap().normalized_storage();\n+ let b = ItemId::parse(\"~/topic/b\").unwrap().normalized_storage();\n+ let next = suggest_next_vote_pair(content, &a, &b).expect(\"next sibling pair\");\n+ assert_ne!(\n+ super::canonical_edge_items(&next.0, &next.1),\n+ super::canonical_edge_items(&a, &b)\n+ );\n+ assert!(\n+ next.0.as_str().ends_with(\"/c\") || next.1.as_str().ends_with(\"/c\"),\n+ \"next pair should include the unvoted sibling: {next:?}\"\n+ );\n+ }\n+\n+ #[test]\n+ fn external_resolver_status_markup_reports_success_and_refresh() {\n+ let html = super::external_resolver_status_markup(Ok(2), \"/-/github.com/o/r\").into_string();\n+ assert!(html.contains(\"Imported 2 GitHub items.\"));\n+ assert!(html.contains(\"href=\\\"/-/github.com/o/r\\\"\"));\n+ }\n+\n #[test]\n fn item_page_model_includes_body_and_unranked_without_votes() {\n let mut reduced = ReducerState::default();\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex d5877eeb3632ed5d770f23a04cb4d1881209bc74..a1b929625acbd5298c0cf62f3ca0892079edcb2a 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -36,7 +36,10 @@ pub(crate) use forum::{\n thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post,\n user_can_post_room, user_can_view_room,\n };\n-pub(crate) use garden::{encode_pin_cookie_value, vote_compare_post_success_js, GARDEN_PIN_COOKIE};\n+pub(crate) use garden::{\n+ encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js,\n+ GARDEN_PIN_COOKIE,\n+};\n pub use garden::{\n external_garden_index, external_ontology_path, garden_index, ontology_path,\n room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path,\ndiff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs\nindex 4fdaae150c74ec5162c2accc06d4cc778ad79914..06c560b8eff09b34896d3935d3917fb28f602bc6 100644\n--- a/server/src/scope_rank.rs\n+++ b/server/src/scope_rank.rs\n@@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet};\n \n use crate::path_types::ItemId;\n use crate::ranking::{connected_components_from_voted_pairs, ranked_items_subset, RankedItem};\n-use crate::reducer::ContentState;\n+use crate::reducer::{ContentState, GroupState};\n \n #[derive(Debug, Clone)]\n pub struct ScopedComponent {\n@@ -49,15 +49,16 @@ pub fn resolve_scope(content: &ContentState, specs: &[String]) -> Vec {\n /// Resolve scope specs recursively up to `depth` levels deep.\n /// depth=1 is equivalent to resolve_scope (direct children only).\n /// depth=2 includes grandchildren, etc.\n-pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth: usize) -> Vec {\n+pub fn resolve_scope_recursive(\n+ content: &ContentState,\n+ specs: &[String],\n+ depth: usize,\n+) -> Vec {\n if depth == 0 {\n return vec![];\n }\n let mut visited: HashSet = HashSet::new();\n- let mut frontier: Vec = specs\n- .iter()\n- .filter_map(|s| ItemId::parse(s))\n- .collect();\n+ let mut frontier: Vec = specs.iter().filter_map(|s| ItemId::parse(s)).collect();\n \n for _level in 0..depth {\n let mut next_frontier: Vec = Vec::new();\n@@ -83,7 +84,10 @@ pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth:\n \n /// Build connected-component rankings for an explicit set of item paths.\n /// Use this when scope comes from multiple parents (resolve_scope).\n-pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[ItemId]) -> ChildrenRankings {\n+pub fn build_rankings_for_item_set(\n+ content: &ContentState,\n+ items_in_scope: &[ItemId],\n+) -> ChildrenRankings {\n let group = &content.ranking_group;\n let mut items_in_scope: Vec = items_in_scope.to_vec();\n items_in_scope.sort();\n@@ -158,6 +162,67 @@ pub fn build_children_rankings(content: &ContentState, parent: &ItemId) -> Child\n build_rankings_for_item_set(content, &items)\n }\n \n+pub fn is_pair_voted_in_group(group: &GroupState, a: &ItemId, b: &ItemId) -> bool {\n+ let Some(&a_idx) = group.item_to_idx.get(a) else {\n+ return false;\n+ };\n+ let Some(&b_idx) = group.item_to_idx.get(b) else {\n+ return false;\n+ };\n+ let (i, j) = if a_idx < b_idx {\n+ (a_idx, b_idx)\n+ } else {\n+ (b_idx, a_idx)\n+ };\n+ group.voted_pairs.contains(&(i, j))\n+}\n+\n+fn canonical_pair(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {\n+ let ac = a.clone().normalized_storage();\n+ let bc = b.clone().normalized_storage();\n+ if ac.as_str() <= bc.as_str() {\n+ (ac, bc)\n+ } else {\n+ (bc, ac)\n+ }\n+}\n+\n+pub fn suggest_next_pair_in_pool(\n+ group: &GroupState,\n+ pool: &[ItemId],\n+ current_pair: Option<(&ItemId, &ItemId)>,\n+) -> Option<(ItemId, ItemId)> {\n+ let current = current_pair.map(|(a, b)| canonical_pair(a, b));\n+ let mut pool = pool.to_vec();\n+ pool.sort();\n+ pool.dedup();\n+ if pool.len() < 2 {\n+ return None;\n+ }\n+\n+ for i in 0..pool.len() {\n+ for j in (i + 1)..pool.len() {\n+ let pair = canonical_pair(&pool[i], &pool[j]);\n+ if current.as_ref() == Some(&pair) {\n+ continue;\n+ }\n+ if !is_pair_voted_in_group(group, &pool[i], &pool[j]) {\n+ return Some((pool[i].clone(), pool[j].clone()));\n+ }\n+ }\n+ }\n+\n+ for i in 0..pool.len() {\n+ for j in (i + 1)..pool.len() {\n+ let pair = canonical_pair(&pool[i], &pool[j]);\n+ if current.as_ref() != Some(&pair) {\n+ return Some((pool[i].clone(), pool[j].clone()));\n+ }\n+ }\n+ }\n+ None\n+}\n+\n #[cfg(test)]\n mod tests {\n use super::*;\n@@ -167,10 +232,7 @@ mod tests {\n let mut item_children: HashMap> = HashMap::new();\n for (parent, children) in edges {\n let parent = ItemId::parse(parent).unwrap();\n- let set: HashSet = children\n- .iter()\n- .map(|s| ItemId::parse(s).unwrap())\n- .collect();\n+ let set: HashSet = children.iter().map(|s| ItemId::parse(s).unwrap()).collect();\n item_children.insert(parent, set);\n }\n ContentState {\n@@ -187,9 +249,13 @@ mod tests {\n \n #[test]\n fn resolve_one_scope_literal() {\n- let content = content_with_children(&[\n- (\"https://slug.social/models\", &[\"https://slug.social/models/x\", \"https://slug.social/models/y\"]),\n- ]);\n+ let content = content_with_children(&[(\n+ \"https://slug.social/models\",\n+ &[\n+ \"https://slug.social/models/x\",\n+ \"https://slug.social/models/y\",\n+ ],\n+ )]);\n let out = resolve_one_scope(&content, \"models\");\n assert_eq!(out.len(), 2);\n assert!(out.contains(&ItemId::parse(\"https://slug.social/models/x\").unwrap()));\n@@ -199,8 +265,14 @@ mod tests {\n #[test]\n fn resolve_scope_multiple_parents_merges() {\n let content = content_with_children(&[\n- (\"https://slug.social/a\", &[\"https://slug.social/a/1\", \"https://slug.social/a/2\"]),\n- (\"https://slug.social/b\", &[\"https://slug.social/b/1\", \"https://slug.social/b/2\"]),\n+ (\n+ \"https://slug.social/a\",\n+ &[\"https://slug.social/a/1\", \"https://slug.social/a/2\"],\n+ ),\n+ (\n+ \"https://slug.social/b\",\n+ &[\"https://slug.social/b/1\", \"https://slug.social/b/2\"],\n+ ),\n ]);\n let out = resolve_scope(&content, &[\"a\".into(), \"b\".into()]);\n assert_eq!(out.len(), 4);\n@@ -209,4 +281,28 @@ mod tests {\n assert!(out.contains(&ItemId::parse(\"https://slug.social/b/1\").unwrap()));\n assert!(out.contains(&ItemId::parse(\"https://slug.social/b/2\").unwrap()));\n }\n+\n+ #[test]\n+ fn suggest_next_pair_skips_current_and_voted_pairs() {\n+ let mut group = crate::reducer::GroupState::new();\n+ let a = ItemId::parse(\"~/a\").unwrap().normalized_storage();\n+ let b = ItemId::parse(\"~/b\").unwrap().normalized_storage();\n+ let c = ItemId::parse(\"~/c\").unwrap().normalized_storage();\n+ group.apply_vote(crate::reducer::VoteData {\n+ ts: 1,\n+ a: a.clone(),\n+ b: b.clone(),\n+ ratio_left: 2,\n+ ratio_right: 1,\n+ body: \"a beats b\".to_string(),\n+ principal: \"tester\".to_string(),\n+ delegate: None,\n+ thread_tag: \"vote\".to_string(),\n+ });\n+ let next =\n+ suggest_next_pair_in_pool(&group, &[a.clone(), b.clone(), c.clone()], Some((&a, &b)))\n+ .expect(\"next pair\");\n+ assert!(next.0 == c || next.1 == c);\n+ assert_ne!(canonical_pair(&next.0, &next.1), canonical_pair(&a, &b));\n+ }\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 8a80fed0a6bc7ae3516c76ab9d1e8592d7038ad1..48298e2e66456268d23a6462536eb32bfeb5f29b 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -27,6 +27,8 @@ pub struct PendingSession {\n pub provider_id: Option,\n /// When set, successful OAuth completion redeems this invite token and appends [`crate::events::GrantAdded`].\n pub redeem_invite: Option,\n+ /// Local path to navigate to after browser onboarding completes.\n+ pub redirect_next: Option,\n pub complete: Option<(String /*username*/, String /*bearer*/)>,\n }\n \ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex fdcde86c718eb53cce8273e45a9844c2d8041f88..184e11a590e7019773f7f0abfa41e79161556c71 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -930,6 +930,34 @@ button.ont-garden-pin-ico:focus-visible {\n outline: 2px solid var(--link);\n outline-offset: 2px;\n }\n+.resolver-actions {\n+ display: flex;\n+ flex-wrap: wrap;\n+ align-items: center;\n+ gap: 8px;\n+}\n+.resolver-refresh-link,\n+.vote-compare-next {\n+ border: var(--bv) solid;\n+ border-color: var(--hi) var(--lo) var(--lo) var(--hi);\n+ color: var(--ui);\n+ font-size: 12px;\n+ padding: 4px 10px;\n+ text-decoration: none;\n+}\n+.resolver-refresh-link:hover,\n+.vote-compare-next:hover {\n+ color: var(--signal);\n+}\n+.resolver-status {\n+ margin-top: 8px;\n+}\n+.resolver-status p {\n+ margin: 0;\n+}\n+.resolver-status-error {\n+ color: var(--danger, #b00020);\n+}\n body.view-ontology-light ol.ont-ranking-list li,\n body.view-ontology-light ul.ont-group-list li {\n display: flex;\n@@ -959,15 +987,19 @@ body.view-vote-compare .vote-compare-shell > h2 {\n color: var(--meta);\n }\n .vote-compare-pair {\n- display: flex;\n- flex-wrap: wrap;\n- align-items: center;\n+ display: grid;\n+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);\n+ align-items: start;\n gap: 10px 16px;\n- margin: 10px 0;\n+ margin: 0 0 10px;\n }\n .vote-compare-item {\n+ display: inline-block;\n text-decoration: none;\n }\n+.vote-compare-right {\n+ text-align: right;\n+}\n .vote-compare-item code {\n font-size: 13px;\n }\n@@ -980,6 +1012,33 @@ body.view-vote-compare .vote-compare-shell > h2 {\n letter-spacing: 0.1em;\n text-transform: uppercase;\n }\n+.vote-compare-item-body {\n+ margin-top: 8px;\n+ max-height: 42dvh;\n+ overflow: auto;\n+}\n+.vote-compare-item-body pre {\n+ background: var(--g1);\n+ font-size: 13px;\n+ line-height: 1.35;\n+ padding: 8px 10px;\n+}\n+.vote-compare-item-body-empty {\n+ font-size: 12px;\n+ margin: 8px 0 0;\n+}\n+.vote-compare-nav {\n+ display: flex;\n+ flex-wrap: wrap;\n+ gap: 8px;\n+ justify-content: center;\n+ margin: 12px 0;\n+}\n+.vote-compare-next.is-disabled {\n+ color: var(--meta);\n+ cursor: default;\n+ opacity: 0.65;\n+}\n .vote-compare-slider-label {\n display: flex;\n flex-wrap: wrap;\n@@ -1060,18 +1119,27 @@ body.view-vote-compare-fullscreen {\n width: 100%;\n margin-left: 0;\n margin-right: 0;\n- padding: clamp(8px, 2.5vw, 28px);\n- padding-bottom: max(20px, env(safe-area-inset-bottom, 0px));\n- padding-top: max(8px, env(safe-area-inset-top, 0px));\n+ padding: 0;\n min-height: 100vh;\n min-height: 100dvh;\n box-sizing: border-box;\n }\n+body.view-vote-compare-fullscreen > .view-meta {\n+ bottom: 4px;\n+ position: fixed;\n+ right: 6px;\n+ z-index: 20;\n+}\n body.view-vote-compare-fullscreen .vote-compare-shell {\n max-width: none;\n width: 100%;\n box-sizing: border-box;\n margin: 0;\n+ min-height: 100dvh;\n+ padding: 0;\n+}\n+body.view-vote-compare-fullscreen .vote-compare-shell > h2 {\n+ display: none;\n }\n \n .vote-edge-history-title {\ndiff --git a/server/static/theme_retro.css b/server/static/theme_retro.css\nindex 61e1448b2f66a075c0e33325d6980448712fc927..6747f59eb1ec5c335029fe92d4e5c55b3125a210 100644\n--- a/server/static/theme_retro.css\n+++ b/server/static/theme_retro.css\n@@ -135,6 +135,34 @@ body.view-ontology nav.breadcrumb a:hover {\n body.view-ontology nav.breadcrumb a.bc-current { color: #111; font-weight: 600; }\n body.view-ontology nav.breadcrumb .bc-sep { color: #888; padding: 0 2px; }\n \n+body.view-ontology .resolver-actions,\n+body.view-ontology .vote-compare-nav {\n+ display: flex;\n+ flex-wrap: wrap;\n+ align-items: center;\n+ gap: 0.5rem;\n+}\n+body.view-ontology .resolver-refresh-link,\n+body.view-ontology .vote-compare-next {\n+ border: 1px solid #bbb;\n+ color: #23a;\n+ padding: 0.2rem 0.55rem;\n+ text-decoration: none;\n+}\n+body.view-ontology .vote-compare-next.is-disabled {\n+ color: #666;\n+ opacity: 0.65;\n+}\n+body.view-ontology .resolver-status {\n+ margin-top: 0.5rem;\n+}\n+body.view-ontology .resolver-status p {\n+ margin: 0;\n+}\n+body.view-ontology .resolver-status-error {\n+ color: #9b1c1c;\n+}\n+\n body.view-ontology ol.ont-ranking-list {\n counter-reset: ont-rank;\n list-style: none;\n@@ -205,16 +233,48 @@ body.view-vote-compare-fullscreen {\n max-width: none;\n width: 100%;\n margin: 0;\n- padding: clamp(8px, 2.5vw, 20px);\n- padding-bottom: max(24px, env(safe-area-inset-bottom, 0px));\n- padding-top: max(8px, env(safe-area-inset-top, 0px));\n+ padding: 0;\n min-height: 100vh;\n min-height: 100dvh;\n box-sizing: border-box;\n }\n+body.view-vote-compare-fullscreen > .view-meta {\n+ bottom: 4px;\n+ position: fixed;\n+ right: 6px;\n+ z-index: 20;\n+}\n body.view-vote-compare-fullscreen .vote-compare-shell {\n max-width: none;\n width: 100%;\n box-sizing: border-box;\n margin: 0;\n+ min-height: 100dvh;\n+ padding: 0;\n+}\n+body.view-vote-compare-fullscreen .vote-compare-shell > h2 {\n+ display: none;\n+}\n+body.view-ontology .vote-compare-pair {\n+ align-items: start;\n+ display: grid;\n+ gap: 0.65rem 1rem;\n+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);\n+ margin: 0 0 0.75rem;\n+}\n+body.view-ontology .vote-compare-item {\n+ display: inline-block;\n+}\n+body.view-ontology .vote-compare-right {\n+ text-align: right;\n+}\n+body.view-ontology .vote-compare-item-body {\n+ margin-top: 0.45rem;\n+ max-height: 42dvh;\n+ overflow: auto;\n+}\n+body.view-ontology .vote-compare-item-body pre {\n+ background: #faf8f3;\n+ border: 1px solid #ccc;\n+ padding: 0.5rem 0.65rem;\n }\ndiff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css\nindex 7da102040484c887833158a37c307d078205c701..55d984a6dd70ebeadeca7baef86b844955f1d78c 100644\n--- a/server/static/theme_retro_craft.css\n+++ b/server/static/theme_retro_craft.css\n@@ -742,6 +742,34 @@ body.view-ontology button.ont-garden-pin-ico:focus-visible {\n outline-offset: 2px;\n }\n \n+body.view-ontology .resolver-actions {\n+ display: flex;\n+ flex-wrap: wrap;\n+ align-items: center;\n+ gap: 0.5rem;\n+}\n+body.view-ontology .resolver-refresh-link {\n+ border: 1px solid #c8c4bc;\n+ color: #3d3a34;\n+ font-family: var(--font-ui);\n+ font-size: 0.72rem;\n+ padding: 0.25rem 0.55rem;\n+ text-decoration: none;\n+}\n+body.view-ontology .resolver-refresh-link:hover {\n+ border-color: #a68e6b;\n+ color: #1a1814;\n+}\n+body.view-ontology .resolver-status {\n+ margin-top: 0.5rem;\n+}\n+body.view-ontology .resolver-status p {\n+ margin: 0;\n+}\n+body.view-ontology .resolver-status-error {\n+ color: #8b1f1f;\n+}\n+\n body.view-ontology ol.ont-ranking-list {\n counter-reset: ont-rank;\n list-style: none;\n@@ -809,21 +837,26 @@ body.view-ontology.view-vote-compare .vote-compare-shell {\n body.view-ontology.view-vote-compare.view-vote-compare-fullscreen .vote-compare-shell {\n max-width: none;\n width: 100%;\n- min-height: calc(100dvh - clamp(20px, 5vw, 56px));\n+ min-height: 100dvh;\n margin: 0;\n+ padding: 0;\n box-sizing: border-box;\n }\n body.view-vote-compare-fullscreen {\n max-width: none !important;\n width: 100%;\n margin: 0 !important;\n- padding: clamp(6px, 2vw, 1.75rem);\n- padding-bottom: max(1.25rem, env(safe-area-inset-bottom, 0px));\n- padding-top: max(6px, env(safe-area-inset-top, 0px));\n+ padding: 0;\n min-height: 100vh;\n min-height: 100dvh;\n box-sizing: border-box;\n }\n+body.view-vote-compare-fullscreen > .view-meta {\n+ bottom: 0.25rem;\n+ position: fixed;\n+ right: 0.35rem;\n+ z-index: 20;\n+}\n body.view-ontology.view-vote-compare .vote-compare-shell > h2 {\n margin-top: 0;\n font-size: 0.7rem;\n@@ -831,12 +864,15 @@ body.view-ontology.view-vote-compare .vote-compare-shell > h2 {\n text-transform: uppercase;\n color: #5c574e;\n }\n+body.view-ontology.view-vote-compare-fullscreen .vote-compare-shell > h2 {\n+ display: none;\n+}\n body.view-ontology .vote-compare-pair {\n- display: flex;\n- flex-wrap: wrap;\n- align-items: center;\n+ display: grid;\n+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);\n+ align-items: start;\n gap: 0.65rem 1rem;\n- margin: 0.65rem 0 0.85rem;\n+ margin: 0 0 0.85rem;\n }\n body.view-ontology .vote-compare-vs {\n color: #8a857a;\n@@ -844,14 +880,62 @@ body.view-ontology .vote-compare-vs {\n font-size: 0.72rem;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n+ padding-top: 0.18rem;\n }\n body.view-ontology .vote-compare-item {\n+ display: inline-block;\n text-decoration: none;\n }\n+body.view-ontology .vote-compare-right {\n+ text-align: right;\n+}\n body.view-ontology .vote-compare-item:hover code {\n border-color: #a68e6b;\n background: #f0ebe3;\n }\n+body.view-ontology .vote-compare-item-body {\n+ margin-top: 0.45rem;\n+ max-height: 42dvh;\n+ overflow: auto;\n+}\n+body.view-ontology .vote-compare-item-body pre {\n+ background: #fdfcfa;\n+ border: 1px solid #d4cfc4;\n+ border-left: 3px solid #a68e6b;\n+ color: #1a1814;\n+ font-size: 0.82rem;\n+ line-height: 1.35;\n+ padding: 0.55rem 0.65rem;\n+}\n+body.view-ontology .vote-compare-item-body-empty {\n+ font-size: 0.78rem;\n+ margin: 0.45rem 0 0;\n+}\n+body.view-ontology .vote-compare-nav {\n+ display: flex;\n+ flex-wrap: wrap;\n+ gap: 0.45rem;\n+ justify-content: center;\n+ margin: 0.75rem 0;\n+}\n+body.view-ontology .vote-compare-next {\n+ border: 1px solid #c8c4bc;\n+ color: #3d3a34;\n+ font-family: var(--font-ui);\n+ font-size: 0.72rem;\n+ letter-spacing: 0.04em;\n+ padding: 0.25rem 0.65rem;\n+ text-decoration: none;\n+}\n+body.view-ontology .vote-compare-next:hover {\n+ border-color: #a68e6b;\n+ color: #1a1814;\n+}\n+body.view-ontology .vote-compare-next.is-disabled {\n+ color: #8a857a;\n+ cursor: default;\n+ opacity: 0.65;\n+}\n \n body.view-ontology .vote-thread-picker {\n margin: 0.85rem 0;\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex 3c9f0476c58abdc947fd50424bfea66a784fb83b..fb0b9335440181d4d50104d37926b0b2eeeb602a 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -8,9 +8,9 @@ use slugsocial_server::{\n spawn_writer_actor_for_test,\n state::{AppConfig, AppState},\n };\n+use std::net::SocketAddr;\n use tempfile::TempDir;\n use tokio::net::TcpListener;\n-use std::net::SocketAddr;\n \n fn sha256_hex(s: &str) -> String {\n let mut hasher = Sha256::new();\n@@ -94,7 +94,13 @@ async fn seed_test_token(state: &AppState) {\n r.apply_event(ev);\n }\n \n-async fn create_test_server_with_state() -> (SocketAddr, TempDir, EventLog, AppState, tokio::task::JoinHandle<()>) {\n+async fn create_test_server_with_state() -> (\n+ SocketAddr,\n+ TempDir,\n+ EventLog,\n+ AppState,\n+ tokio::task::JoinHandle<()>,\n+) {\n let tmp = TempDir::new().unwrap();\n let log_path = tmp.path().join(\"events.jsonl\");\n let log = EventLog::new(&log_path);\n@@ -131,7 +137,11 @@ async fn create_test_server() -> (SocketAddr, TempDir, EventLog, tokio::task::Jo\n async fn test_healthz() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\n let client = reqwest::Client::new();\n- let response = client.get(&format!(\"http://{}/healthz\", addr)).send().await.unwrap();\n+ let response = client\n+ .get(&format!(\"http://{}/healthz\", addr))\n+ .send()\n+ .await\n+ .unwrap();\n assert!(response.status().is_success());\n assert_eq!(response.text().await.unwrap(), \"ok\");\n }\n@@ -189,7 +199,13 @@ async fn test_room_delete_rpc() {\n del[\"results\"][0]\n );\n \n- let list = rpc_batch(&client, addr, Some(&bearer), serde_json::json!([\"RoomList\"])).await;\n+ let list = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([\"RoomList\"]),\n+ )\n+ .await;\n let rooms = list[\"results\"][0][\"result\"][\"RoomList\"][\"rooms\"]\n .as_array()\n .unwrap();\n@@ -278,7 +294,11 @@ async fn test_private_room_forum_read_requires_bearer() {\n )\n .await;\n let line_na = &no_auth[\"results\"][0];\n- assert_eq!(line_na[\"ok\"], false, \"expected failure without bearer: {:?}\", line_na);\n+ assert_eq!(\n+ line_na[\"ok\"], false,\n+ \"expected failure without bearer: {:?}\",\n+ line_na\n+ );\n assert_eq!(line_na[\"error\"], \"room not found\");\n \n let with_auth = rpc_batch(\n@@ -300,7 +320,11 @@ async fn test_private_room_forum_read_requires_bearer() {\n )\n .await;\n let line_ok = &with_auth[\"results\"][0];\n- assert_eq!(line_ok[\"ok\"], true, \"expected success with bearer: {:?}\", line_ok);\n+ assert_eq!(\n+ line_ok[\"ok\"], true,\n+ \"expected success with bearer: {:?}\",\n+ line_ok\n+ );\n let total = line_ok[\"result\"][\"ForumThread\"][\"total\"].as_u64().unwrap();\n assert!(total >= 1);\n }\n@@ -499,7 +523,11 @@ async fn test_feed_since_last_post_is_scoped_to_delegate() {\n )\n .await;\n let steal_line = &steal[\"results\"][0];\n- assert_eq!(steal_line[\"ok\"], false, \"expected rejection for unbound delegate: {:?}\", steal_line);\n+ assert_eq!(\n+ steal_line[\"ok\"], false,\n+ \"expected rejection for unbound delegate: {:?}\",\n+ steal_line\n+ );\n assert_eq!(steal_line[\"error\"], \"not your delegate\");\n \n let no_auth = rpc_batch(\n@@ -852,24 +880,24 @@ async fn test_choose_username_returns_evalable_js() {\n \n {\n let mut sessions = state.pending_sessions.write().await;\n- let pending = sessions.get_mut(session).expect(\"pending session must exist\");\n+ let pending = sessions\n+ .get_mut(session)\n+ .expect(\"pending session must exist\");\n pending.provider = Some(\"google\".to_string());\n pending.provider_id = Some(\"google-user-123\".to_string());\n }\n \n let choose = client\n .post(format!(\"http://{addr}/auth/choose-username\"))\n- .form(&[\n- (\"session\", session),\n- (\"username\", \"webuser\"),\n- ])\n+ .form(&[(\"session\", session), (\"username\", \"webuser\")])\n .send()\n .await\n .unwrap();\n \n assert_eq!(choose.status(), reqwest::StatusCode::OK);\n assert_eq!(\n- choose.headers()\n+ choose\n+ .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok()),\n Some(\"text/javascript; charset=utf-8\")\n@@ -880,6 +908,48 @@ async fn test_choose_username_returns_evalable_js() {\n assert!(body.contains(\"window.location = \\\"/auth/complete\\\"\"));\n }\n \n+#[tokio::test]\n+async fn test_choose_username_carries_redirect_next() {\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ let client = reqwest::Client::builder()\n+ .redirect(reqwest::redirect::Policy::none())\n+ .build()\n+ .unwrap();\n+\n+ let start = client\n+ .post(format!(\"http://{addr}/api/v0/pending-session\"))\n+ .json(&serde_json::json!({\n+ \"agent\": \"00000000-0000-0000-0000-000000000123:test:web/form\"\n+ }))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert!(start.status().is_success());\n+ let start_json: serde_json::Value = start.json().await.unwrap();\n+ let session = start_json[\"session\"].as_str().unwrap();\n+\n+ {\n+ let mut sessions = state.pending_sessions.write().await;\n+ let pending = sessions\n+ .get_mut(session)\n+ .expect(\"pending session must exist\");\n+ pending.provider = Some(\"google\".to_string());\n+ pending.provider_id = Some(\"google-user-redirect\".to_string());\n+ pending.redirect_next = Some(\"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\".to_string());\n+ }\n+\n+ let choose = client\n+ .post(format!(\"http://{addr}/auth/choose-username\"))\n+ .form(&[(\"session\", session), (\"username\", \"redirectuser\")])\n+ .send()\n+ .await\n+ .unwrap();\n+\n+ assert_eq!(choose.status(), reqwest::StatusCode::OK);\n+ let body = choose.text().await.unwrap();\n+ assert!(body.contains(\"window.location = \\\"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\\\"\"));\n+}\n+\n #[tokio::test]\n async fn test_sse_stream_emits_evalable_js_after_post() {\n let (addr, _tmp, _log, _state, _handle) = create_test_server_with_state().await;\n@@ -903,7 +973,10 @@ async fn test_sse_stream_emits_evalable_js_after_post() {\n let room_path = format!(\"/r/{room_seg}\");\n \n let sse_resp = client\n- .get(format!(\"http://{addr}/sse?path={}\", urlencoding::encode(&room_path)))\n+ .get(format!(\n+ \"http://{addr}/sse?path={}\",\n+ urlencoding::encode(&room_path)\n+ ))\n .send()\n .await\n .unwrap();\n@@ -1153,7 +1226,9 @@ async fn test_post_redact_removes_garden_and_marks_thread() {\n .await;\n let ra_line = &rank_after[\"results\"][0];\n assert_eq!(ra_line[\"ok\"], true);\n- let comps = ra_line[\"result\"][\"GardenRank\"][\"components\"].as_array().unwrap();\n+ let comps = ra_line[\"result\"][\"GardenRank\"][\"components\"]\n+ .as_array()\n+ .unwrap();\n assert!(\n comps.is_empty() || comps[0][\"ranking\"].as_array().unwrap().is_empty(),\n \"votes from redacted post should be removed: {:?}\",\n@@ -1267,7 +1342,9 @@ async fn test_check_endpoint_does_not_commit() {\n let threads_body = rpc_batch(&client, addr, None, list_batch).await;\n let tline = &threads_body[\"results\"][0];\n assert_eq!(tline[\"ok\"], true);\n- let threads = tline[\"result\"][\"ForumThreads\"][\"threads\"].as_array().unwrap();\n+ let threads = tline[\"result\"][\"ForumThreads\"][\"threads\"]\n+ .as_array()\n+ .unwrap();\n assert!(threads.is_empty());\n }\n \n@@ -1316,7 +1393,11 @@ async fn test_garden_item_pair_matchup_include_threads() {\n .iter()\n .filter_map(|t| t.as_str())\n .collect();\n- assert!(threads.contains(&\"sorting-hat\"), \"item threads should contain sorting-hat: {:?}\", threads);\n+ assert!(\n+ threads.contains(&\"sorting-hat\"),\n+ \"item threads should contain sorting-hat: {:?}\",\n+ threads\n+ );\n \n let pair_body = rpc_batch(\n &client,\n@@ -1337,7 +1418,11 @@ async fn test_garden_item_pair_matchup_include_threads() {\n .iter()\n .filter_map(|t| t.as_str())\n .collect();\n- assert!(pair_threads.contains(&\"sorting-hat\"), \"pair threads should contain sorting-hat: {:?}\", pair_threads);\n+ assert!(\n+ pair_threads.contains(&\"sorting-hat\"),\n+ \"pair threads should contain sorting-hat: {:?}\",\n+ pair_threads\n+ );\n \n let matchup_body = rpc_batch(\n &client,\n@@ -1429,7 +1514,11 @@ async fn test_garden_pair_and_rank_path_not_found_consistent() {\n )\n .await;\n let pair2_line = &pair2[\"results\"][0];\n- assert_eq!(pair2_line[\"ok\"], true, \"GetPair after parent materialized: {:?}\", pair2_line);\n+ assert_eq!(\n+ pair2_line[\"ok\"], true,\n+ \"GetPair after parent materialized: {:?}\",\n+ pair2_line\n+ );\n assert!(pair2_line[\"result\"][\"Pair\"].is_object());\n }\n \n@@ -1490,7 +1579,11 @@ async fn test_view_counts_increment_and_display() {\n .send()\n .await\n .unwrap();\n- assert!(v1.status().is_success(), \"vote compare GET 1: {}\", v1.status());\n+ assert!(\n+ v1.status().is_success(),\n+ \"vote compare GET 1: {}\",\n+ v1.status()\n+ );\n let v1_body = v1.text().await.unwrap();\n assert!(\n v1_body.contains(\"1 views\"),\n@@ -1502,7 +1595,11 @@ async fn test_view_counts_increment_and_display() {\n .send()\n .await\n .unwrap();\n- assert!(v2.status().is_success(), \"vote compare GET 2: {}\", v2.status());\n+ assert!(\n+ v2.status().is_success(),\n+ \"vote compare GET 2: {}\",\n+ v2.status()\n+ );\n let v2_body = v2.text().await.unwrap();\n assert!(\n v2_body.contains(\"2 views\"),\n@@ -1576,13 +1673,18 @@ async fn test_rank_history() {\n let history = resp[\"history\"].as_array().unwrap();\n assert_eq!(history.len(), 1, \"one ingest → one history entry\");\n let entry = &history[0];\n- assert_eq!(entry[\"scope_rank\"], 1, \"rust should be #1 in scope after first ingest\");\n- assert_eq!(entry[\"scope_rank_delta\"], 0, \"delta is 0 on first appearance\");\n+ assert_eq!(\n+ entry[\"scope_rank\"], 1,\n+ \"rust should be #1 in scope after first ingest\"\n+ );\n+ assert_eq!(\n+ entry[\"scope_rank_delta\"], 0,\n+ \"delta is 0 on first appearance\"\n+ );\n let caused_by = entry[\"caused_by\"].as_array().unwrap();\n assert_eq!(caused_by.len(), 2, \"both votes in the ingest touched rust\");\n assert_eq!(\n- entry[\"thread_post_index\"],\n- 0,\n+ entry[\"thread_post_index\"], 0,\n \"rank history links use same 0-based index as /t/hist-test/0\"\n );\n \n@@ -1610,16 +1712,16 @@ async fn test_rank_history() {\n \n let caused_by2 = hist2[1][\"caused_by\"].as_array().unwrap();\n assert_eq!(caused_by2.len(), 1);\n- assert!(caused_by2[0][\"a\"].as_str().unwrap().ends_with(\"python\") ||\n- caused_by2[0][\"b\"].as_str().unwrap().ends_with(\"python\"));\n+ assert!(\n+ caused_by2[0][\"a\"].as_str().unwrap().ends_with(\"python\")\n+ || caused_by2[0][\"b\"].as_str().unwrap().ends_with(\"python\")\n+ );\n assert_eq!(\n- hist2[0][\"thread_post_index\"],\n- 0,\n+ hist2[0][\"thread_post_index\"], 0,\n \"first hist-test post is chronological index 0\"\n );\n assert_eq!(\n- hist2[1][\"thread_post_index\"],\n- 1,\n+ hist2[1][\"thread_post_index\"], 1,\n \"second ingest is chronological index 1\"\n );\n \n@@ -1675,12 +1777,27 @@ async fn pair_returns_connectivity_stats() {\n let pair = &pair_body[\"results\"][0][\"result\"][\"Pair\"];\n \n let conn = &pair[\"connectivity\"];\n- assert!(!conn.is_null(), \"pair response should include connectivity stats\");\n+ assert!(\n+ !conn.is_null(),\n+ \"pair response should include connectivity stats\"\n+ );\n assert_eq!(conn[\"items\"].as_u64().unwrap(), 4, \"4 items in scope\");\n- assert_eq!(conn[\"components\"].as_u64().unwrap(), 3, \"3 components (1 connected + 2 isolates)\");\n- assert_eq!(conn[\"comparisons_until_connected\"].as_u64().unwrap(), 2, \"need 2 more comparisons\");\n+ assert_eq!(\n+ conn[\"components\"].as_u64().unwrap(),\n+ 3,\n+ \"3 components (1 connected + 2 isolates)\"\n+ );\n+ assert_eq!(\n+ conn[\"comparisons_until_connected\"].as_u64().unwrap(),\n+ 2,\n+ \"need 2 more comparisons\"\n+ );\n assert_eq!(conn[\"pairs_voted\"].as_u64().unwrap(), 1, \"1 pair voted\");\n- assert_eq!(conn[\"pairs_possible\"].as_u64().unwrap(), 6, \"4*3/2 = 6 possible pairs\");\n+ assert_eq!(\n+ conn[\"pairs_possible\"].as_u64().unwrap(),\n+ 6,\n+ \"4*3/2 = 6 possible pairs\"\n+ );\n \n let doc2 = serde_json::json!([{\n \"Post\": {\n@@ -1693,8 +1810,7 @@ async fn pair_returns_connectivity_stats() {\n }]);\n let resp2 = rpc_batch(&client, addr, Some(&test_bearer()), doc2).await;\n assert_eq!(\n- resp2[\"results\"][0][\"ok\"],\n- true,\n+ resp2[\"results\"][0][\"ok\"], true,\n \"second ingest failed: {:?}\",\n resp2\n );\n@@ -1713,8 +1829,19 @@ async fn pair_returns_connectivity_stats() {\n .await;\n let pair2 = &pair_body2[\"results\"][0][\"result\"][\"Pair\"];\n let conn2 = &pair2[\"connectivity\"];\n- assert_eq!(conn2[\"components\"].as_u64().unwrap(), 2, \"2 components after connecting c\");\n- assert_eq!(conn2[\"comparisons_until_connected\"].as_u64().unwrap(), 1, \"1 more comparison to connect\");\n- assert_eq!(conn2[\"pairs_voted\"].as_u64().unwrap(), 2, \"2 pairs voted now\");\n+ assert_eq!(\n+ conn2[\"components\"].as_u64().unwrap(),\n+ 2,\n+ \"2 components after connecting c\"\n+ );\n+ assert_eq!(\n+ conn2[\"comparisons_until_connected\"].as_u64().unwrap(),\n+ 1,\n+ \"1 more comparison to connect\"\n+ );\n+ assert_eq!(\n+ conn2[\"pairs_voted\"].as_u64().unwrap(),\n+ 2,\n+ \"2 pairs voted now\"\n+ );\n }\n-\ndiff --git a/test/browser_github_resolver.clj b/test/browser_github_resolver.clj\nindex 1d5b6f1c4575e67ec430e4ce7f02c1bca5a35775..fee272099457ab1f4478fff9aed0f5c08ea2e85e 100644\n--- a/test/browser_github_resolver.clj\n+++ b/test/browser_github_resolver.clj\n@@ -21,6 +21,18 @@\n (do (Thread/sleep 200) (recur))\n false))))))\n \n+(defn- wait-for-http-text [url expected timeout-ms]\n+ (let [deadline (+ (System/currentTimeMillis) timeout-ms)]\n+ (loop []\n+ (let [text (try\n+ (:body (oauth/http-get url))\n+ (catch Exception _ nil))]\n+ (if (and (string? text) (str/includes? text expected))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false))))))\n+\n (defn- start-mock-github [port]\n (let [!paths (atom [])\n handler (fn [req]\n@@ -112,12 +124,21 @@\n (locator/click (page/locator pg \"[data-testid=\\\"github-resolve-children\\\"]\"))\n (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/pulls\" 15000)\n \"repo resolver imports structural children\")\n+ (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/commits\" 15000)\n+ \"repo resolver imports commits section\")\n+ (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/releases\" 15000)\n+ \"repo resolver imports releases section\")\n \n (page/navigate pg (str base-url \"/-/github.com/octo/hello/issues/42\"))\n (locator/click (page/locator pg \"[data-testid=\\\"github-resolve-siblings\\\"]\"))\n- (page/navigate pg (str base-url \"/-/github.com/octo/hello/issues\"))\n- (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/issues/43\" 15000)\n- \"issue sibling resolver imports issue siblings\")))))\n+ (is (wait-for-http-text (str base-url \"/-/github.com/octo/hello/issues\")\n+ \"-/github.com/octo/hello/issues/43\"\n+ 15000)\n+ \"issue sibling resolver persisted issue siblings\")\n+ (core/with-page [pg2 (core/new-page-from-context ctx)]\n+ (page/navigate pg2 (str base-url \"/-/github.com/octo/hello/issues\"))\n+ (is (wait-for-text pg2 \"body\" \"-/github.com/octo/hello/issues/43\" 15000)\n+ \"issue sibling resolver imports issue siblings\"))))))\n \n (is (some #{\"/users/octo/repos\"} @(:paths @!github))\n \"mock GitHub saw user repos request\")\ndiff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj\nindex f97a4a45e89c388f093694a1bf296f674bcb377a..7cd1e467fdd8a4480df1be2afd78adb45b9cdc6d 100644\n--- a/test/browser_vote_compare.clj\n+++ b/test/browser_vote_compare.clj\n@@ -50,9 +50,11 @@\n thread-tag \"browser-vote-compare\"\n left-url \"https://slug.social/~/gp-vote-a\"\n right-url \"https://slug.social/~/gp-vote-b\"\n+ third-url \"https://slug.social/~/gp-vote-c\"\n raw (str \"# \" thread-tag \"\\n\\n\"\n \"~/gp-vote-a {one}\\n\"\n \"~/gp-vote-b {two}\\n\"\n+ \"~/gp-vote-c {three}\\n\"\n \"{seed edge vote}\\n\"\n \"~/gp-vote-a 1:1 ~/gp-vote-b\\n\")\n post-resp (oauth/http-post-json\n@@ -64,7 +66,7 @@\n :headers {\"Authorization\" (str \"Bearer \" alice-token)})\n post-json (json/parse-string (:body post-resp) false)\n _ (is (true? (get-in post-json [\"results\" 0 \"ok\"])) \"seed items + edge vote via rpc\")\n- cmp-url (str base-url \"/vote/compare?left=\" (enc left-url) \"&right=\" (enc right-url))]\n+ cmp-url (str base-url \"/vote/compare?left=\" (enc left-url) \"&right=\" (enc third-url))]\n (core/with-playwright [pw]\n (core/with-browser [browser (core/launch-chromium pw {:headless true :channel \"chrome\"})]\n (core/with-context [ctx (core/new-context browser)]\n@@ -74,12 +76,17 @@\n (page/navigate pg cmp-url)\n ;; No .vote-compare-shell wrapper — wait on stable vote-compare UI instead.\n (is (wait-for-text pg \"body.view-vote-compare\" \"compare\" 15000) \"vote compare page\")\n- (is (wait-for-text pg \"ul.vote-edge-history\" \"seed edge vote\" 15000)\n- \"edge history lists canonical-order vote\")\n+ (is (wait-for-text pg \"#vote-edge-history-region\" \"no votes on this pair\" 15000)\n+ \"current pair starts without edge history\")\n (locator/fill (page/locator pg \"#vote-explain\") \"because playwright says so\")\n (locator/click (page/locator pg \"#vote-compare-form button[type=submit]\"))\n (is (wait-for-text pg \"ul.vote-edge-history\" \"because playwright\" 20000)\n- \"new vote appears in edge history after morph\"))))))\n+ \"new vote appears in edge history after morph\")\n+ (locator/click (page/locator pg \"[data-testid=\\\"vote-next-pair\\\"]\"))\n+ (is (wait-for-text pg \".vote-compare-pair\" \"~/gp-vote-b\" 15000)\n+ \"post-success next-pair nav points to a different pair\")\n+ (is (wait-for-text pg \".vote-compare-pair\" \"~/gp-vote-c\" 15000)\n+ \"next pair keeps the unvoted third item\"))))))\n \n (finally\n (when-some [s @!server] (common/kill-server s))\n","role":"user"}],"model":"openai/gpt-chat-latest"}