constitution · epochs · watch · epoch 3

comparison

c_25ece8f92dc8 (tommy-mor) vs c_b0194743d156 (tommy-mor)

download prompt · raw event · cmp_85c9ae3c9f0d26

council reasoning

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

Side A fixes a genuine multi-user correctness bug (shared sentinel delegate silently locking out other human voters) with a minimal, well-scoped change plus a targeted regression test proving the fix. Side B also fixes a real bug (inconsistent canonical root path causing lookup mismatches) but does so with a much larger, more invasive refactor across path types touching many call sites, which carries more risk despite good test coverage; both are solid but A's fix is more surgical and addresses a more severe functional defect (broken voting) with lower blast radius.

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

A removes false shared browser agent sentinels and models PendingSession.agent as Option, fixing multi-user votes blocked by AgentBound and aligning browser posts with real no-delegate humans; B mainly unifies tilde-root URL spellings and lookup normalization. A’s identity/delegate design change is the sharper, more product-critical correctness fix, while B is important path hygiene with more surface/tests but narrower lasting model impact.

openai/gpt-chat-latest · winner B · 3:2 · permalink

Side B consolidates canonical path handling by introducing shared helpers (`tilde_http_path_to_canonical`, normalized ontology-root storage) and updates ranking, breadcrumb, and garden code to use normalized keys, fixing root-path inconsistencies and adding broad regression coverage. Side A correctly removes browser sentinel delegates by making pending-session agents optional and prevents shared delegate binding for browser users, but its scope is narrower than B's cross-cutting path normalization and routing improvements.

sides

A — c_25ece8f92dc8 (tommy-mor)

message

[b7626603] Remove browser sentinel delegates so multi-user votes work.

Shared WEB_BROWSER_AGENT bound on first vote and blocked every later human; browser posts now use no delegate, matching forum UI.

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

diff preview

diff --git a/cli/src/main.rs b/cli/src/main.rs
index 70435b412188a151c5e89e842a5de57f7480ddf2..a4a22724fc9e27879951f520a9241d5832982a70 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1645,8 +1645,8 @@ async fn run() -> Result<()> {
                     tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;
                     let poll: PendingSessionPollResponse =
                         expect_json(client.get(&poll_url).send().await?).await?;
-                    if !poll.agent.trim().is_empty() {
-                        agent_out = Some(poll.agent.clone());
+                    if let Some(a) = poll.agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+                        agent_out = Some(a.to_string());
                     }
                     if poll.complete {
                         token_out = poll.token;
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index bfddcce4dc1a77571d297648dd840e6c5bc194f8..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -29,12 +29,6 @@ use crate::{
     write_cmd::WriteCmd,
 };
 
-/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
-const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
-
-/// Agent id for `/login` browser OAuth (no CLI); must pass [`parse_agent`].
-pub const WEB_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000001:social:web/browser";
-
 /// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses.
 pub const SLUG_SESSION_COOKIE: &str = "slug_session";
 
@@ -282,7 +276,7 @@ pub async fn get_join_invite(
     let session = format!("p_{}", uuid::Uuid::new_v4().simple());
     let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()));
     let s = PendingSession {
-        agent: INVITE_BROWSER_AGENT.to_string(),
+        agent: None,
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
@@ -549,7 +543,7 @@ pub async fn post_choose_username(
     };
 
     let sessions = pending_sessions(&state);
-    let (provider, provider_id, agent) = {
+    let (provider, provider_id) = {
         let sessions_read = sessions.read().await;
         let Some(s) = sessions_read.get(&form.session) else {
             return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
@@ -560,14 +554,9 @@ pub async fn post_choose_username(
         let Some(provider_id) = s.provider_id.clone() else {
             return js_form_error_fragment(&form.session, "oauth not completed").into_response();
         };
-        (provider, provider_id, s.agent.clone())
+        (provider, provider_id)
     };
 
-    if let Err(msg) = parse_agent(&agent) {
-        return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}"))
-            .into_response();
-    }
-
     let redeem_invite = {
         let sessions_read = sessions.read().await;
         sessions_read
@@ -637,7 +626,8 @@ pub async fn get_web_login(
     let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()))
         .or_else(|| Some("/".to_string()));
     let s = PendingSession {
-        agent: WEB_BROWSER_AGENT.to_string(),
+        // Humans sign in via the website with no AI delegate.
+        agent: None,
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
@@ -698,7 +688,7 @@ pub async fn post_pending_session(
     );
     let poll_url = format!("/api/v0/pending-session/{session}");
     let s = PendingSession {
-        agent: agent_naked,
+        agent: Some(agent_naked),
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 578e5844e86f7c2666c8e37ef9fa90c01d20d134..57a0139928b09034122398f8f3362cbdbcc5862e 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -276,7 +276,7 @@ async fn dispatch_ui_action(
                 &session.bearer,
                 room.clone(),
                 thread_tag.clone(),
-                Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),
+                None,
                 text,
             )
             .await
diff --git a/server/src/state.rs b/server/src/state.rs
index 648ab5304764a329fcabbbbcd3782b94e3e005a8..8ea84dcf9b1df8f8037e913cdd94e5908e6d5d55 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -21,7 +21,8 @@ pub struct InviteState {
 
 #[derive(Debug, Clone)]
 pub struct PendingSession {
-    pub agent: String,
+    /// CLI `identity start` delegate (`uuid:rig:model`). `None` for browser `/login` and `/join`.
+    pub agent: Option<String>,
     pub created_ts: i64,
     pub provider: Option<String>,
     pub provider_id: Option<String>,
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index d242abff5769cc704b3c8070d7456e5788c31a45..22b16c87d5ecdb5272a017af70b5353d8c39c6e8 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -418,6 +418,99 @@ async fn test_web_login_carries_vote_pair_next_into_pending_session() {
     let sessions = state.pending_sessions.read().await;
     let pending = sessions.get(&session).expect("pending session");
     assert_eq!(pending.redirect_next.as_deref(), Some(next));
+    assert_eq!(
+        pending.agent, None,
+        "browser /login must not invent a sentinel delegate"
+    );
+}
+
+#[tokio::test]
+async fn test_vote_compare_two_users_both_succeed_without_delegate() {
+    let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+    let client = reqwest::Client::new();
+    let alice = test_bearer();
+    let bob = seed_user_token(&state, "bob", "bobtok", "bobsecret").await;
+
+    // Define items first (votes require existing item bodies).
+    let seed = ui_post_ingest_rpc(
+        "public",
+        "multi-vote",
+        "~/multi-a {alpha}\n~/multi-b {beta}\n",
+    );
+    let seed_resp = client
+        .post(format!("http://{addr}/ui"))
+        .header("Authorization", format!("Bearer {alice}"))
+        .form(&[("__rpc__", seed.as_str())])
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(seed_resp.status(), reqwest::StatusCode::OK);
+    let seed_js = seed_resp.text().await.unwrap();
+    assert!(
+        !seed_js.contains("auth-error"),
+        "item seed must succeed, got: {seed_js}"
+    );
+
+    for (bearer, left, right, explanation) in [
+        (&alice, "3", "1", "alice prefers a"),
+        (&bob, "1", "3", "bob prefers b"),
+    ] {
+        let rpc = ui_vote_compare_post_rpc(
+            "public",
+            "multi-vote",
+            "~/multi-a",
+            "~/multi-b",
+            left,
+            right,
+            explanation,
+        );
+        let resp = client
+            .post(format!("http://{addr}/ui"))
+            .header("Authorization", format!("Bearer {bearer}"))
+            .form(&[("__rpc__", rpc.as_str())])
+            .send()
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), reqwest::StatusCode::OK);
+        let js = resp.text().await.unwrap();
+        assert!(
+            !js.contains("delegate already bound"),
+            "human vote must not hit shared-sentinel AgentBound ({explanation}), got: {js}"
+        );
+        assert!(
+            !js.contains("auth-error"),
+            "human vote must succeed ({explanation}), got: {js}"
+        );
+        assert!(
+            js.contains("vote-edge-history-region"),
+            "vote should morph edge history ({explanation}), got: {js}"
+        );
+    }
+
+    let reduced = state.reduced.read().await;
+    let human_votes: Vec<_> = reduced
+        .ingests_ordered
+        .iter()
+        .filter_map(|id| reduced.ingests_by_id.get(id))
+        .filter(|ing| ing.raw.contains("prefers"))
+        .collect();
+    assert_eq!(human_votes.len(), 2, "expected two vote ingests");
+    let mut principals: Vec<&str> = human_votes.iter().map(|i| i.principal.as_str()).collect();
+    principals.sort();
+    assert_eq!(principals, ["bob", "testuser"]);
+    for ing in &human_votes {
+        assert!(
+            ing.delegate.is_none(),
+            "browser votes must have no delegate, principal={} delegate={:?}",
+            ing.principal,
+            ing.delegate
+        );
+    }
+    assert!(
+        reduced.agent_bindings.is_empty(),
+        "human votes must not create AgentBound entries: {:?}",
+        reduced.agent_bindings
+    );
 }
 
 #[tokio::test]
diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs
index 4a620eaa875e7a1145f2a4e82cc4ff2be21d5345..a5306fa2c4c9fda47e43fc0de42407b66e23eced 100644
--- a/server/tests/support/mod.rs
+++ b/server/tests/support/mod.rs
@@ -70,20 +70,25 @@ pub async fn rpc_batch(
     response.json().await.unwrap()
 }
 
-pub async fn seed_test_token(state: &AppState) {
+/// Seed a user + bearer into reducer state (not appended to the event log).
+/// Returns the `slug_<token_id>_<secret>` bearer string.
+pub async fn seed_user_token(
+    state: &AppState,
+    username: &str,
+    token_id: &str,
+    secret: &str,
+) -> String {
     let registered = Event::UserRegistered(UserRegistered {
         ts: 0,
-        username: "testuser".to_string(),
+        username: username.to_string(),
         provider: "test".to_string(),
-        provider_id: "testuser".to_string(),
+        provider_id: username.to_string(),
     });
-    let token_id = "testtok";
-    let secret = "secret";
     let salt = "salt";
     let token_hash = sha256_hex(&format!("{salt}:{secret}"));
     let ev = Event::TokenIssued(TokenIssued {
         ts: 0,
-        username: "testuser".to_string(),
+        username: username.to_string(),
         token_id: token_id.to_string(),
         token_hash,
         salt: salt.to_string(),
@@ -92,6 +97,11 @@ pub async fn seed_test_token(state: &AppState) {
     let mut r = state.reduced.write().await;
     r.apply_event(registered);
     r.apply_event(ev);
+    format!("slug_{token_id}_{secret}")
+}
+
+pub async fn seed_test_token(state: &AppState) {
+    let _ = seed_user_token(state, "testuser", "testtok", "secret").await;
 }
 
 pub async fn create_test_server_with_state() -> (
diff --git a/test/oauth.clj b/test/oauth.clj
index 3cbf496005e906ad6c579d44ac8c4055ee60d063..6911ac34a55707acda9f9ce091c079964649a252 100644
--- a/test/oauth.clj
+++ b/test/oauth.clj
@@ -186,8 +186,9 @@
               (:token poll-json))))))))
 
 (defn fetch-bearer-token!
-  "Simulate browser OAuth + username choice; returns `slug_…` bearer token.
-   Ingest `--delegate` must match this agent string for `AgentBound` on first write."
+  "Simulate CLI identity OAuth + username choice; returns `slug_…` bearer token.
+   Pass `:agent` (default local/dev) when the test will CLI-ingest with `--delegate`
+   so first write can `AgentBound`. Browser UI posts use no delegate."
   [base-url & {:keys [username agent] :or {username "intuser" agent default-agent}}]
   (let [token (complete-registration! base-url :username username :agent agent)]
     (when-not (str/starts-with? token "slug_")
diff --git a/types/src/lib.rs b/types/src/lib.rs
index 211493935d19582607f5c87fb492faf47bdc6f53..0c4295b15908ea7a2303dac4c42015ded51e4d73 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -535,7 +535,9 @@ pub struct PendingSessionStartResponse {
 pub struct PendingSessionPollResponse {
     pub ok: bool,
     pub complete: bool,
-    pub agent: String,
+    /// Present for CLI identity sessions; omitted for browser `/login` / `/join` (no delegate).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub agent: Option<String>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub user: Option<String>,
     #[serde(skip_serializing_if = "Option::is_none")]

download full diff A

B — c_b0194743d156 (tommy-mor)

message

[c59951f5] fixed canonical item paths business

diff preview

diff --git a/server/src/html/breadcrumb_path.rs b/server/src/html/breadcrumb_path.rs
index 12ad2c84faf2fbb693015d4552e45b5c54d587b9..c8a3937923161a6ff248bd77e87eff0dc6fe9ab0 100644
--- a/server/src/html/breadcrumb_path.rs
+++ b/server/src/html/breadcrumb_path.rs
@@ -1,4 +1,4 @@
-use crate::path_types::CanonicalItemUrl;
+use crate::path_types::{tilde_http_path_to_canonical, CanonicalItemUrl};
 
 /// Semantic view of an ontology path for rendering and routing decisions.
 pub(super) struct OntologyPath {
@@ -11,16 +11,7 @@ impl OntologyPath {
     /// Path is the `*path` segment from `/~/*path` (e.g. `topic/a`). Always treat it as under `~/`
     /// so it canonicalizes to `https://slug.social/~/…`, not the non-tilde site path.
     pub(super) fn from_input(path: &str) -> Self {
-        let p = path.trim_start_matches('/');
-        let raw = if p.starts_with("http://") || p.starts_with("https://") {
-            p.to_string()
-        } else if p.is_empty() {
-            "~/".to_string()
-        } else {
-            format!("~/{}", p)
-        };
-        let canonical = CanonicalItemUrl::parse(&raw)
-            .unwrap_or_else(|| CanonicalItemUrl::parse("~/").unwrap());
+        let canonical = tilde_http_path_to_canonical(path);
         Self::from_canonical(canonical)
     }
 
@@ -37,7 +28,7 @@ impl OntologyPath {
     }
 
     pub(super) fn root() -> Self {
-        Self::from_canonical(CanonicalItemUrl::parse("~/").unwrap())
+        Self::from_canonical(CanonicalItemUrl::ontology_root())
     }
 
     pub(super) fn is_root(&self) -> bool {
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index d58d9b46f575eb6b7e4971086b07dda75ca2f645..319feb15a6b68d2b5df98b4289fedbc9bdd048d3 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -459,6 +459,8 @@ struct ItemPageViewModel {
     item: String,
     body: Option<String>,
     sibling_rank: Option<SiblingRank>,
+    /// False at the tilde ontology root (`~/`): sibling-rank footnote does not apply.
+    item_has_parent: bool,
     child_rankings: ChildrenRankings,
     rank_history: Vec<RankHistoryEntryView>,
     /// Forum threads that mention or vote on this item.
@@ -470,11 +472,12 @@ fn build_sibling_rank(
     scope: &ScopeId,
     item: &CanonicalItemUrl,
 ) -> Option<SiblingRank> {
+    let item = item.clone().normalized_storage();
     let content = reduced
         .content_for_scope(scope)
         .unwrap_or_else(|| reduced.public());
     let group = &content.ranking_group;
-    let parent = item.parent()?;
+    let parent = item.parent()?.normalized_storage();
     let siblings: Vec<CanonicalItemUrl> = content
         .item_children
         .get(&parent)
@@ -492,7 +495,7 @@ fn build_sibling_rank(
     if scoped_idxs.is_empty() {
         return None;
     }
-    let current_idx = *group.item_to_idx.get(item)?;
+    let current_idx = *group.item_to_idx.get(&item)?;
     if !scoped_idxs.contains(&current_idx) {
         return None;
     }
@@ -520,7 +523,7 @@ fn build_sibling_rank(
         .filter_map(|li| local_to_global.get(*li).copied())
         .collect();
     let ranked = ranked_items_subset(group, &comp_global, 10000, 1e-8);
-    let position = ranked.iter().position(|r| &r.item == item)? + 1;
+    let position = ranked.iter().position(|r| r.item == item)? + 1;
     Some(SiblingRank {
         position,
         component_size: ranked.len(),
@@ -597,7 +600,9 @@ fn build_item_page_view_model(
         .content_for_scope(scope)
         .unwrap_or_else(|| reduced.public());
     let item_key = CanonicalItemUrl::parse(item)
-        .unwrap_or_else(|| CanonicalItemUrl::parse("~/").unwrap());
+        .unwrap_or_else(|| CanonicalItemUrl::parse("~/").unwrap())
+        .normalized_storage();
+    let item_has_parent = item_key.parent().is_some();
     let child_rankings = build_children_rankings(content, &item_key);
 
     let rank_history = build_rank_history(reduced, scope, item_key.as_str());
@@ -617,6 +622,7 @@ fn build_item_page_view_model(
             .cloned()
             .or_else(|| reduced.public().item_bodies.get(&item_key).cloned()),
         sibling_rank: build_sibling_rank(reduced, scope, &item_key),
+        item_has_parent,
         child_rankings,
         rank_history,
         threads,
@@ -652,7 +658,7 @@ async fn render_scope_view(
                             (format!("#{} of {}", rank.position, rank.component_size))
                         }
                         span class="muted" { (format!("({} siblings)", rank.sibling_total)) }
-                    } @else {
+                    } @else if model.item_has_parent {
                         span class="muted" { "unranked among siblings" }
                     }
                 }
@@ -815,6 +821,18 @@ mod tests {
         }));
     }
 
+    fn apply_ingest_room(state: &mut ReducerState, ts: i64, room_id: &str, raw: &str) {
+        state.apply_event(Event::Ingest(Ingest {
+            ts,
+            id: format!("ing-{ts}"),
+            raw: raw.to_string(),
+            principal: "testuser".to_string(),
+            delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
+            room_id: room_id.to_string(),
+            thread_tag: String::new(),
+        }));
+    }
+
     #[test]
     fn item_page_model_includes_body_and_unranked_without_votes() {
         let mut reduced = ReducerState::default();
@@ -885,4 +903,85 @@ mod tests {
                 || model.child_rankings.unranked_items.contains(&CanonicalItemUrl("https://slug.social/~/topic/kid2".to_string()))
         );
     }
+
+    #[test]
+    fn item_page_room_scope_root_lists_top_level_children() {
+        let mut reduced = ReducerState::default();
+        apply_ingest_room(
+            &mut reduced,
+            1,
+            "9ab12cd/my-room",
+            "@00000000-0000-0000-0000-000000000000:test:local/test\n~/t1 {a}\n~/t2 {b}\n",
+        );
+        use crate::path_types::CanonicalItemUrl;
+        let root = CanonicalItemUrl::ontology_root();
+        let model = build_item_page_view_model(
+            &reduced,
+            &ScopeId::Room("9ab12cd/my-room".to_string()),
+            root.as_str(),
+        );
+        assert!(!model.item_has_parent);
+        assert_eq!(model.child_rankings.unranked_items.len(), 2);
+        let set: std::collections::HashSet<&str> = model
+            .child_rankings
+            .unranked_items
+            .iter()
+            .map(|u| u.as_str())
+            .collect();
+        assert!(set.contains("https://slug.social/~/t1"));
+        assert!(set.contains("https://slug.social/~/t2"));
+    }
+
+    /// Top-level `~/a` vs `~/b` votes form one ranked component under the ontology root.
+    #[test]
+    fn item_page_room_scope_root_shows_ranked_child_group() {
+        let mut reduced = ReducerState::default();
+        apply_ingest_room(
+            &mut reduced,
+            1,
+            "9ab12cd/my-room",
+            "@00000000-0000-0000-0000-000000000000:test:local/test\n\
+             ~/a {a}\n~/b {b}\n~/a 2:1 ~/b {because}\n",
+        );
+        use crate::path_types::CanonicalItemUrl;
+        let root = CanonicalItemUrl::ontology_root();
+        let model = build_item_page_view_model(
+            &reduced,
+            &ScopeId::Room("9ab12cd/my-room".to_string()),
+            root.as_str(),
+        );
+        assert_eq!(model.child_rankings.component_rankings.len(), 1);
+        assert_eq!(model.child_rankings.component_rankings[0].pairs, 1);
+        let names: Vec<&str> = model.child_rankings.component_rankings[0]
+            .ranked
+            .iter()
+            .map(|r| r.item.as_str())
+            .collect();
+        assert_eq!(
+            names,
+            vec!["https://slug.social/~/a", "https://slug.social/~/b"]
+        );
+        assert!(model.child_rankings.unranked_items.is_empty());
+    }
+
+    /// Legacy `https://slug.social/~/` spelling still resolves children under the real root key.
+    #[test]
+    fn item_page_model_normalizes_legacy_tilde_root_storage_url() {
+        let mut reduced = ReducerState::default();
+        apply_ingest(
+            &mut reduced,
+            1,
+            "@00000000-0000-0000-0000-000000000000:test:local/test\n~/x {x}\n",
+        );
+        let model = build_item_page_view_model(
+            &reduced,
+            &ScopeId::Public,
+            "https://slug.social/~/",
+        );
+        assert_eq!(model.child_rankings.unranked_items.len(), 1);
+        assert_eq!(
+            model.child_rankings.unranked_items[0].as_str(),
+            "https://slug.social/~/x"
+        );
+    }
 }
diff --git a/server/src/path_types.rs b/server/src/path_types.rs
index 4c8075bbd488f1b9a5eda9e03ced83f6238c6d5e..361a9d446c9043cbdea5f060db2e8633c2fd9bf9 100644
--- a/server/src/path_types.rs
+++ b/server/src/path_types.rs
@@ -1,3 +1,5 @@
 //! Re-exports — implementations live in `slug-types` (`paths` module).
 
-pub use slug_types::paths::{CanonicalItemUrl, RelativePath, TildePath};
+pub use slug_types::paths::{
+    tilde_http_path_to_canonical, CanonicalItemUrl, RelativePath, TildeHttpPathTail, TildePath,
+};
diff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs
index dc640848232b7e79142bfeeea3003c9023f84854..d656b2f0a0a3623ca6b234b746beaaca8ae6017d 100644
--- a/server/src/scope_rank.rs
+++ b/server/src/scope_rank.rs
@@ -149,9 +149,10 @@ pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[Can
 /// Build connected-component rankings for direct children of parent_scope.
 /// Matches the HTML garden view: multiple components, isolates, no-vote items.
 pub fn build_children_rankings(content: &ContentState, parent: &CanonicalItemUrl) -> ChildrenRankings {
+    let parent = parent.clone().normalized_storage();
     let items: Vec<CanonicalItemUrl> = content
         .item_children
-        .get(parent)
+        .get(&parent)
         .map(|s| s.iter().cloned().collect())
         .unwrap_or_default();
     build_rankings_for_item_set(content, &items)
diff --git a/server/tests/integration.rs b/server/tests/integration.rs
index f9c218378f6a173e56ae1cec797b3c492986eac0..d4c5bfe9c6f1c71dc61878bd8c5e729b1b7c69ef 100644
--- a/server/tests/integration.rs
+++ b/server/tests/integration.rs
@@ -704,6 +704,62 @@ async fn test_private_room_post_links_use_private_garden_routes() {
     assert!(garden_body.contains(&format!("/r/{room_short}/{room_slug}/t/garden-thread")));
 }
 
+#[tokio::test]
+async fn test_private_room_garden_root_lists_top_level_tilde_children() {
+    let (addr, _tmp, _log, _handle) = create_test_server().await;
+    let client = reqwest::Client::builder()
+        .redirect(reqwest::redirect::Policy::none())
+        .build()
+        .unwrap();
+    let bearer = test_bearer();
+
+    let create = rpc_batch(
+        &client,
+        addr,
+        Some(&bearer),
+        serde_json::json!([{
+            "RoomCreate": { "slug": "garden-root-list" }
+        }]),
+    )
+    .await;
+    let room_id = create["results"][0]["result"]["RoomCreated"]["room_id"]
+        .as_str()
+        .unwrap()
+        .to_string();
+    let (room_short, room_slug) = room_id.split_once('/').unwrap();
+
+    let rpc = ui_post_ingest_rpc(
+        &room_id,
+        "ing",
+        "~/test1 {wow}\n~/test2 {wow2}\n~/test1 2:1 ~/test2 {because}\n",
+    );
+    let post = client
+        .post(format!("http://{addr}/ui"))
+        .header("Authorization", format!("Bearer {bearer}"))
+        .form(&[("__rpc__", rpc.as_str())])
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(post.status(), reqwest::StatusCode::OK);
+
+    let root_page = client
+        .get(format!("http://{addr}/r/{room_short}/{room_slug}/~"))
+        .header("Authorization", format!("Bearer {bearer}"))
+        .send()
+        .await
+        .unwrap();
+    assert!(root_page.status().is_success());
+    let body = root_page.text().await.unwrap();
+ 

… preview truncated; 14,119 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.