constitution · epochs · watch · epoch 3

comparison

c_afa638171cf7 (tommy-mor) vs c_77729db919ab (tommy-mor)

download prompt · raw event · cmp_a6502a6bc27f59

council reasoning

~anthropic/claude-sonnet-latest · winner B · 6:4 · permalink

Side B introduces a genuinely reusable, composable URL canonicalization engine (url_rules/engine.rs + registry.rs) replacing ad-hoc string manipulation in path_types.rs, fixing structural issues (proper scheme handling, generalizable per-domain rules, YouTube support) with real unit test coverage of edge cases like breadcrumb phantom segments. Side A adds real feature value (Reddit OAuth linking, UUID-based identity) but is more feature-bolt-on with less architectural improvement, and its projection_apply logic for pending in-batch weight reads is a minor but real fix; both are substantive, but B's refactor yields a cleaner, more maintainable foundation for id canonicalization used project-wide.

~x-ai/grok-latest · winner A · 3:2 · permalink

A lands a real identity-model change (UUID-only principal, multi-provider link/attach/conflict flows, private linked-provider UI) plus full Reddit OAuth with mocks, routes, and trust-weight batching—behavioral product surface, not renames. B’s new url_rules engine and https-canonical ItemIds are lasting foundation work, but much of the patch is mechanical ID-string churn across tests after the format scramble.

openai/gpt-chat-latest · winner A · 5:1 · permalink

Side A implements a significant authentication and identity architecture change: UUID becomes the canonical identity, OAuth accounts become linkable providers, Reddit OAuth is added alongside GitHub, provider-link conflict handling is implemented, linked providers are tracked privately, and the projection logic is fixed so trust-weight updates see earlier writes in the same batch. Side B introduces a useful URL canonicalization framework and migrates IDs to full HTTPS URLs, but much of the patch is mechanical updates to tests and string literals, making its lasting impact smaller than the functional identity and authentication changes in Side A.

sides

A — c_afa638171cf7 (tommy-mor)

message

[52f5c51c] Add Reddit OAuth linking and make UUID the only account identity.

OAuth providers only attach to a session UUID (first link creates the
principal); linked providers stay private on the account page.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/AGENTS.md b/AGENTS.md
index 6e0fd8ebb65d665c9c1438e3275971d62b98fd95..e9cc3173dbeb21ad0fc090ca7b407b027c7820a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,8 +35,11 @@ Environment variables (defaults in `server/src/state.rs`):
 - `SORTER2_DATA_DIR` — default `./data` (created on startup)
 - `SORTER2_EVENT_LOG` — default `{data_dir}/events.jsonl`
 - `SORTER2_BASE_URL` — public origin (also drives Secure cookies when `https://`)
-- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth (optional; login disabled if unset)
-- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` (tests only)
+- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth linking (optional)
+- `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` (or `REDDIT_APP_*`) — Reddit API import + OAuth linking (optional)
+- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` and `/auth/reddit` (tests only)
+
+Identity: UUID is canonical. OAuth providers only *link* to a UUID (first link creates the principal). Linked providers are private to the account owner.
 
 Health check: `GET /healthz` → `ok`.
 
diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs
index 5906f93b13853421e96a3c37bc9d8202a47842bf..c706ae8045a811e5941f5f6c72da88f42a403a82 100644
--- a/server/src/auth/mod.rs
+++ b/server/src/auth/mod.rs
@@ -1,4 +1,8 @@
-//! GitHub OAuth login, session cookies, and vote actor resolution.
+//! OAuth linking, session cookies, and vote actor resolution.
+//!
+//! Canonical identity is a UUID. OAuth providers only *link* to that UUID
+//! (first link creates the principal; later links attach while logged in).
+//! Which providers are linked is private to the account owner.
 
 pub mod config;
 pub mod identity;
@@ -22,7 +26,9 @@ use crate::{
     form_template::template_json_compact,
     html::layout,
     state::AppState,
-    storage_schema::{oauth_link_owner, pseudonym_owner, Store, StoreFields},
+    storage_schema::{
+        linked_providers_for_uuid, oauth_link_owner, pseudonym_owner, Store, StoreFields,
+    },
     ui_action::UI_RPC_FIELD,
 };
 
@@ -53,10 +59,12 @@ fn new_actor_uuid() -> String {
 pub struct LoginQuery {
     #[serde(default)]
     pub return_to: Option<String>,
+    #[serde(default)]
+    pub error: Option<String>,
 }
 
 #[derive(Debug, Deserialize)]
-pub struct GitHubStartQuery {
+pub struct OAuthStartQuery {
     #[serde(default)]
     pub return_to: Option<String>,
     #[serde(default)]
@@ -72,15 +80,22 @@ fn return_from_query_or_jar(jar: &CookieJar, query: Option<&str>) -> String {
         .unwrap_or_else(|| "/".to_string())
 }
 
-fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, String)> {
+/// Available OAuth link targets: `(provider_key, label, start_href)`.
+fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, &'static str, String)> {
     let mut out = Vec::new();
+    let enc = urlencoding::encode(return_to);
     if oauth::GitHubConfig::from_env(base_url).is_some() {
         out.push((
-            "GitHub",
-            format!(
-                "/auth/github?return_to={}",
-                urlencoding::encode(return_to)
-            ),
+            "github",
+            oauth::provider_label("github"),
+            format!("/auth/github?return_to={enc}"),
+        ));
+    }
+    if oauth::RedditConfig::from_env(base_url).is_some() {
+        out.push((
+            "reddit",
+            oauth::provider_label("reddit"),
+            format!("/auth/reddit?return_to={enc}"),
         ));
     }
     out
@@ -125,23 +140,41 @@ fn alias_claim_forms(return_to: &str, submit_label: &str) -> Result<Markup, Stat
     })
 }
 
-fn signed_out_body(providers: &[(&str, String)]) -> Markup {
+fn login_error_message(code: Option<&str>) -> Option<&'static str> {
+    match code {
+        Some("oauth_taken") => {
+            Some("that OAuth account is already linked to a different sorter2 account")
+        }
+        Some("oauth_failed") => Some("OAuth failed — try again"),
+        _ => None,
+    }
+}
+
+fn signed_out_body(
+    providers: &[(&str, &str, String)],
+    error: Option<&str>,
+) -> Markup {
     html! {
         main class="panel login-page" {
             section class="login-section" {
                 h1 { "sign in" }
-                p class="muted" { "link an account to vote under a lasting alias" }
+                p class="muted" {
+                    "link an OAuth account to create your identity, then claim an alias to vote"
+                }
+                @if let Some(msg) = login_error_message(error) {
+                    p class="alias-bad" data-testid="login-error" { (msg) }
+                }
                 @if providers.is_empty() {
                     p class="muted" {
-                        "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET."
+                        "OAuth is not configured. Set GitHub and/or Reddit client credentials."
                     }
                 } @else {
                     ul class="oauth-provider-list" {
-                        @for (name, href) in providers {
+                        @for (key, label, href) in providers {
                             li {
                                 a href=(href) class="btn-primary oauth-provider"
-                                    data-testid=(format!("oauth-{}", name.to_lowercase())) {
-                                    (format!("Continue with {name}"))
+                                    data-testid=(format!("oauth-{key}")) {
+                                    (format!("Link {label}"))
                                 }
                             }
                         }
@@ -156,7 +189,10 @@ fn signed_out_body(providers: &[(&str, String)]) -> Markup {
 fn account_body(
     actor: &session::SessionActor,
     aliases: &[String],
-    providers: &[(&str, String)],
+    // Provider keys already linked to this UUID (private).
+    linked: &[String],
+    // Providers available to link: not yet attached.
+    unlinkable: &[(&str, &str, String)],
     claim_forms: Markup,
 ) -> Markup {
     let current = actor.pseudonym.trim();
@@ -212,16 +248,29 @@ fn account_body(
                 (claim_forms)
             }
 
-            @if !providers.is_empty() {
-                section class="login-section" {
-                    h2 { "linked sign-in" }
-                    p class="muted small" { "sign in again with the same provider to return to this account" }
+            section class="login-section" {
+                h2 { "linked sign-in" }
+                p class="muted small" {
+                    "private to you — linking more providers raises trust weight without publishing which accounts you use"
+                }
+                @if linked.is_empty() {
+                    p class="muted" data-testid="linked-providers-empty" { "none yet" }
+                } @else {
+                    ul class="linked-provider-list" data-testid="linked-providers" {
+                        @for key in linked {
+                            li data-testid=(format!("linked-{key}")) {
+                                (oauth::provider_label(key))
+                            }
+                        }
+                    }
+                }
+                @if !unlinkable.is_empty() {
                     ul class="oauth-provider-list" {
-                        @for (name, href) in providers {
+                        @for (key, label, href) in unlinkable {
                             li {
                                 a href=(href) class="btn-secondary oauth-provider"
-                                    data-testid=(format!("oauth-relink-{}", name.to_lowercase())) {
-                                    (format!("Re-link {name}"))
+                                    data-testid=(format!("oauth-link-{key}")) {
+                                    (format!("Link {label}"))
                                 }
                             }
                         }
@@ -243,12 +292,21 @@ fn account_body(
 fn login_body(
     session: Option<&session::SessionActor>,
     aliases: &[String],
-    providers: &[(&str, String)],
+    linked: &[String],
+    providers: &[(&str, &str, String)],
     claim_forms: Option<Markup>,
+    error: Option<&str>,
 ) -> Markup {
     match (session, claim_forms) {
-        (Some(actor), Some(forms)) => account_body(actor, aliases, providers, forms),
-        _ => signed_out_body(providers),
+        (Some(actor), Some(forms)) => {
+            let unlinkable: Vec<_> = providers
+                .iter()
+                .filter(|(key, _, _)| !linked.iter().any(|p| p == key))
+                .cloned()
+                .collect();
+            account_body(actor, aliases, linked, &unlinkable, forms)
+        }
+        _ => signed_out_body(providers, error),
     }
 }
 
@@ -268,6 +326,10 @@ pub async fn login_page(
         .as_ref()
         .map(|s| alias_list(db, &s.uuid))
         .unwrap_or_default();
+    let linked = session
+        .as_ref()
+        .map(|s| linked_providers_for_uuid(db, &s.uuid).unwrap_or_default())
+        .unwrap_or_default();
     let providers = oauth_providers(&base_url_from_env(state.cfg.port), &return_to);
 
     let claim_forms = if session.is_some() {
@@ -282,7 +344,14 @@ pub async fn login_page(
         } else {
             "login · sorter2"
         },
-        login_body(session.as_ref(), &aliases, &providers, claim_forms),
+        login_body(
+            session.as_ref(),
+            &aliases,
+            &linked,
+            &providers,
+            claim_forms,
+            query.error.as_deref(),
+        ),
         state.views.get_views("/login"),
         session
             .as_ref()
@@ -302,7 +371,6 @@ pub async fn alias_page(
     let db = state.projection_store.db();
     let session = session::load_valid_session(db, &session_id).ok_or(StatusCode::UNAUTHORIZED)?;
     if session::session_has_pseudonym(&session) {
-        // Already onboarded — manage aliases on the account page.
         return Ok(Redirect::to("/login").into_response());
     }
 
@@ -331,7 +399,7 @@ pub async fn alias_page(
 pub async fn github_start(
     State(state): State<AppState>,
     jar: CookieJar,
-    Query(query): Query<GitHubStartQuery>,
+    Query(query): Query<OAuthStartQuery>,
 ) -> Result<Response, StatusCode> {
     let cfg = oauth::GitHubConfig::from_env(&base_url_from_env(state.cfg.port))
         .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
@@ -342,7 +410,28 @@ pub async fn github_start(
     } else {
         None
     };
-    let url = oauth::authorize_url(&cfg, &state_token, mock_user);
+    let url = oauth::github_authorize_url(&cfg, &state_token, mock_user);
+    let jar = jar
+        .add(session::oauth_state_cookie_value(&state_token))
+        .add(session::auth_return_cookie_value(&return_to));
+    Ok((jar, Redirect::temporary(&url)).into_response())
+}
+
+pub async fn reddit_start(
+    State(state): State<AppState>,
+    jar: CookieJar,
+    Query(query): Query<OAuthStartQuery>,
+) -> Result<Response, StatusCode> {
+    let cfg = oauth::RedditConfig::from_env(&base_url_from_env(state.cfg.port))
+        .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
+    let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref());
+    let state_token = session::new_oauth_state();
+    let mock_user = if config::mock_oauth_allowed() {
+        query.mock_user.as_deref()
+    } else {
+        None
+    };
+    let url = oauth::reddit_authorize_url(&cfg, &state_token, mock_user);
     let jar = jar
         .add(session::oauth_state_cookie_value(&state_token))
         .add(session::auth_return_cookie_value(&return_to));
@@ -355,6 +444,13 @@ pub struct OAuthCallbackQuery {
     pub state: String,
 }
 
+/// Link `provider:provider_id` to a UUID.
+///
+/// - Logged in + new provider → attach to session UUID
+/// - Logged in + already ours → no-op
+/// - Logged in 

… preview truncated; 29,823 characters omitted

download full diff A

B — c_77729db919ab (tommy-mor)

message

[239c074b] url schema stuff

diff preview

diff --git a/AGENTS.md b/AGENTS.md
index 426a88e7c1da54fe0a28c5c76fa4e1f1bc117fcf..e60b9ba6012593361ef10e8fdd9439cd9932e09b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,3 +58,4 @@ Use **tmux** for `cargo run --package sorter2-server` (dev server). Rebuild afte
 
 - First `cargo test` / `cargo build --release` is slow; Clojure smoke test always does a release build.
 - `legacy/` and `ideas/` are not part of the workspace build.
+- **ItemId** for web URLs is a canonical full URL (`https://reddit.com/r/rust`). Rules live in [`server/src/url_rules/`](server/src/url_rules/) (composable Rust, not a config DSL). After changing canonicalization rules, rebuild the projection: `cargo run --package sorter2-server -- replay-index`.
diff --git a/Cargo.lock b/Cargo.lock
index 0dd4fce5fb6400ae153cca4e3dbf5a5158e6d8b4..49a908ef935c430dbe63c6a28d8a24e38b489486 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1951,6 +1951,7 @@ dependencies = [
  "tower-http 0.5.2",
  "tracing",
  "tracing-subscriber",
+ "url",
  "urlencoding",
 ]
 
diff --git a/REPLAY.sh b/REPLAY.sh
new file mode 100755
index 0000000000000000000000000000000000000000..f2dbd8aea60c02d2feef74805f7ef5c2b7022537
--- /dev/null
+++ b/REPLAY.sh
@@ -0,0 +1,2 @@
+cargo run --package sorter2-server -- replay-index
+
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 27f552c20b97ef28cdde4cb6b1a4980375135111..ad4912791aff59fb1d3293f66ad381ae618cd60b 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -24,6 +24,7 @@ async-stream = "0.3"
 futures-util = { version = "0.3", default-features = false, features = ["std"] }
 rand = "0.8"
 urlencoding = "2"
+url = "2"
 durable = { path = "../durable" }
 
 [dev-dependencies]
diff --git a/server/src/entity_store.rs b/server/src/entity_store.rs
index d5d17c3676e4a8ddec998e9f5a9dbafe9c2d9d0e..d29f39aecca6f12cdcf263cf77c3654eb4ee6cfa 100644
--- a/server/src/entity_store.rs
+++ b/server/src/entity_store.rs
@@ -124,7 +124,7 @@ mod tests {
     fn round_trip_payload() {
         let tmp = tempfile::tempdir().unwrap();
         let store = EntityStore::open(tmp.path()).unwrap();
-        let id = ItemId::parse("reddit.com/r/rust").unwrap();
+        let id = ItemId::from_url("https://reddit.com/r/rust").unwrap();
         let payload = json!({"kind": "t5", "data": {"display_name": "rust"}});
 
         store.put(&id, &payload).unwrap();
diff --git a/server/src/event_log.rs b/server/src/event_log.rs
index 36f5b406084065b608735987cdb483c236e03081..2c9290b6fdbf2c2ad1c0f1ffd7374b2d9cc97f36 100644
--- a/server/src/event_log.rs
+++ b/server/src/event_log.rs
@@ -199,7 +199,7 @@ mod tests {
         log.append(&sample_record(
             1,
             Event::NodeEnsured {
-                id: "reddit.com/r/rust".into(),
+                id: "https://reddit.com/r/rust".into(),
             },
         ))
         .await
@@ -237,7 +237,7 @@ mod tests {
         let path = tmp.path().join("events.jsonl");
         let log = EventLog::new(&path);
         let event = Event::NodeEnsured {
-            id: "reddit.com/r/rust".into(),
+            id: "https://reddit.com/r/rust".into(),
         };
         log.append(&sample_record(1, event)).await.unwrap();
 
@@ -255,7 +255,7 @@ mod tests {
         let path = tmp.path().join("events.jsonl");
         std::fs::write(
             &path,
-            r#"{"type":"node_ensured","id":"reddit.com/r/rust"}
+            r#"{"type":"node_ensured","id":"https://reddit.com/r/rust"}
 {"schema":1,"seq":1,"ts":1,"event":{"type":"vote_recorded","ts":1,"a":"a","b":"b","ratio_left":2,"ratio_right":1,"scope":""}}
 "#,
         )
@@ -295,7 +295,7 @@ mod tests {
         log.append(&sample_record(
             1,
             Event::NodeEnsured {
-                id: "reddit.com/r/rust".into(),
+                id: "https://reddit.com/r/rust".into(),
             },
         ))
         .await
@@ -303,7 +303,7 @@ mod tests {
         log.append(&sample_record(
             3,
             Event::NodeEnsured {
-                id: "reddit.com/r/python".into(),
+                id: "https://reddit.com/r/python".into(),
             },
         ))
         .await
diff --git a/server/src/journal.rs b/server/src/journal.rs
index 521a108019de1ea870d14c4fafbfe572c20ce0de..50bc89f976edb82b7b0e49e954a8eccbbe82bf87 100644
--- a/server/src/journal.rs
+++ b/server/src/journal.rs
@@ -141,10 +141,10 @@ mod tests {
         let j2 = journal.clone();
         let (r1, r2) = tokio::join!(
             j1.append(Event::NodeEnsured {
-                id: "reddit.com/r/rust".into(),
+                id: "https://reddit.com/r/rust".into(),
             }),
             j2.append(Event::NodeEnsured {
-                id: "reddit.com/r/python".into(),
+                id: "https://reddit.com/r/python".into(),
             }),
         );
         r1.unwrap();
@@ -153,10 +153,10 @@ mod tests {
         assert_eq!(projection_store.last_applied_event_count().unwrap(), 2);
         let tree = projection_store.load_tree().unwrap();
         assert!(tree
-            .get(&ItemId::parse("reddit.com/r/rust").unwrap())
+            .get(&ItemId::parse("https://reddit.com/r/rust").unwrap())
             .is_some());
         assert!(tree
-            .get(&ItemId::parse("reddit.com/r/python").unwrap())
+            .get(&ItemId::parse("https://reddit.com/r/python").unwrap())
             .is_some());
     }
 
@@ -170,7 +170,7 @@ mod tests {
                 1,
                 1,
                 Event::NodeEnsured {
-                    id: "reddit.com/r/rust".into(),
+                    id: "https://reddit.com/r/rust".into(),
                 },
             ))
             .await
@@ -186,7 +186,7 @@ mod tests {
                 1,
                 1,
                 Event::NodeEnsured {
-                    id: "reddit.com/r/rust".into(),
+                    id: "https://reddit.com/r/rust".into(),
                 },
             )],
         )
@@ -202,7 +202,7 @@ mod tests {
         );
         journal
             .append(Event::NodeEnsured {
-                id: "reddit.com/r/python".into(),
+                id: "https://reddit.com/r/python".into(),
             })
             .await
             .unwrap();
@@ -227,13 +227,13 @@ mod tests {
         journal
             .append_many(vec![
                 Event::NodeEnsured {
-                    id: "reddit.com/r/rust".into(),
+                    id: "https://reddit.com/r/rust".into(),
                 },
                 Event::NodeEnsured {
-                    id: "reddit.com/r/python".into(),
+                    id: "https://reddit.com/r/python".into(),
                 },
                 Event::NodeEnsured {
-                    id: "reddit.com/r/clojure".into(),
+                    id: "https://reddit.com/r/clojure".into(),
                 },
             ])
             .await
@@ -245,7 +245,7 @@ mod tests {
         assert_eq!(projection_store.last_applied_event_count().unwrap(), 3);
         let tree = projection_store.load_tree().unwrap();
         assert!(tree
-            .get(&ItemId::parse("reddit.com/r/clojure").unwrap())
+            .get(&ItemId::parse("https://reddit.com/r/clojure").unwrap())
             .is_some());
     }
 }
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 9bd5f76fd1406b9b1be4c272f4ba8647edde2678..5c02c8e704e4664453bad75d819df8a067668176 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -9,6 +9,7 @@ pub mod journal;
 pub mod pair;
 pub mod parser;
 pub mod path_types;
+pub mod url_rules;
 pub mod projection_apply;
 pub mod projection_store;
 pub mod ranking;
diff --git a/server/src/pair.rs b/server/src/pair.rs
index 43f780ba6ea6ce1cdc2e1f4cbb252ba8a10684b9..815a97b80e3e9f348e0937a4f147f2862018edb0 100644
--- a/server/src/pair.rs
+++ b/server/src/pair.rs
@@ -381,42 +381,42 @@ mod tests {
 
     #[test]
     fn suggest_prefers_unvoted_pair() {
-        let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+        let parent = ItemId::parse("https://reddit.com/r/rust").unwrap();
         let mut tree = seed_children(
             &parent,
             &[
-                "reddit.com/r/rust/a",
-                "reddit.com/r/rust/b",
-                "reddit.com/r/rust/c",
+                "https://reddit.com/r/rust/a",
+                "https://reddit.com/r/rust/b",
+                "https://reddit.com/r/rust/c",
             ],
         );
         let vote =
-            VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap();
+            VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
         tree.apply_vote(&parent, vote);
         let group = tree.get(&parent).unwrap().local_ranking.clone();
         let pool = children_of(&tree, &parent);
         let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
-        let voted_ab = (l.as_str() == "reddit.com/r/rust/a" && r.as_str() == "reddit.com/r/rust/b")
-            || (l.as_str() == "reddit.com/r/rust/b" && r.as_str() == "reddit.com/r/rust/a");
+        let voted_ab = (l.as_str() == "https://reddit.com/r/rust/a" && r.as_str() == "https://reddit.com/r/rust/b")
+            || (l.as_str() == "https://reddit.com/r/rust/b" && r.as_str() == "https://reddit.com/r/rust/a");
         assert!(!voted_ab);
     }
 
     #[test]
     fn suggest_bridges_separate_components() {
-        let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+        let parent = ItemId::parse("https://reddit.com/r/rust").unwrap();
         let mut tree = seed_children(
             &parent,
             &[
-                "reddit.com/r/rust/a",
-                "reddit.com/r/rust/b",
-                "reddit.com/r/rust/c",
-                "reddit.com/r/rust/d",
+                "https://reddit.com/r/rust/a",
+                "https://reddit.com/r/rust/b",
+                "https://reddit.com/r/rust/c",
+                "https://reddit.com/r/rust/d",
             ],
         );
         let ab =
-            VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap();
+            VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
         let cd =
-            VoteData::from_recorded(2, "reddit.com/r/rust/c", "reddit.com/r/rust/d", 2, 1).unwrap();
+            VoteData::from_recorded(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1).unwrap();
         tree.apply_vote(&parent, ab);
         tree.apply_vote(&parent, cd);
         let group = tree.get(&parent).unwrap().local_ranking.clone();
@@ -424,37 +424,37 @@ mod tests {
         let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
         let chosen = pair_set(&pair);
         let from_ab =
-            chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b");
+            chosen.contains("https://reddit.com/r/rust/a") || chosen.contains("https://reddit.com/r/rust/b");
         let from_cd =
-            chosen.contains("reddit.com/r/rust/c") || chosen.contains("reddit.com/r/rust/d");
+            chosen.contains("https://reddit.com/r/rust/c") || chosen.contains("https://reddit.com/r/rust/d");
         assert!(from_ab && from_cd, "expected bridge pair, got {:?}", chosen);
     }
 
     #[test]
     fn suggest_prefers_attach_over_isolate_pair_among_many_unranked() {
-        let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+        let parent = ItemId::parse("https://reddit.com/r/rust").unwrap();
         let mut tree = seed_children(
             &parent,
             &[
-                "reddit.com/r/rust/a",
-                "reddit.com/r/rust/b",
-                "reddit.com/r/rust/c",
-                "reddit.com/r/rust/d",
-                "reddit.com/r/rust/e",
+                "https://reddit.com/r/rust/a",
+                "https://reddit.com/r/rust/b",
+                "https://reddit.com/r/rust/c",
+                "https://reddit.com/r/

… preview truncated; 51,799 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.