constitution · epochs · watch · epoch 3

comparison

c_c0df72aee6da (tommy-mor) vs c_b0194743d156 (tommy-mor)

download prompt · raw event · cmp_f1aa91f9fe1496

council reasoning

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

B fixes a real, subtle correctness bug (inconsistent canonical root URL representations causing sibling/child lookups to silently fail) with a well-reasoned normalization scheme, new unit/integration/browser tests, and clear documentation of the string-kind invariants. A is a legitimate security fix (HTML sanitization) with good targeted tests, but most of its diff is generated Cargo.lock noise from adding a heavyweight dependency (ammonia + ~40 transitive crates), making its actual code contribution much smaller than B's substantive path-handling fix.

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

B fixes foundational CanonicalItemUrl identity (legacy `…/~/` vs `…/~` root keys, normalized_storage, tilde_http_path_to_canonical) so garden child rankings and room/public root pages resolve correctly, backed by unit, integration, and browser tests. A is a real, precise XSS harden (ammonia at PreEscaped render sites plus targeted sanitize tests) but is localized to Reddit HTML embedding, whereas B corrects core path/storage design the app relies on everywhere.

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

Side A closes a concrete security vulnerability by sanitizing untrusted Reddit `body_html` with `ammonia` before every `PreEscaped` render, centralizing the logic in a new `html::sanitize` module and adding tests that verify scripts and event handlers are stripped while benign markup is preserved. Side B is a valuable correctness refactor that normalizes canonical ontology root paths, fixes routing/storage edge cases, and adds extensive regression tests, but its impact is narrower than preventing cross-site scripting in rendered user content.

sides

A — c_c0df72aee6da (tommy-mor)

message

[bf118bdf] Sanitize Reddit entity body HTML before rendering.

Use ammonia at render time so untrusted selftext_html cannot execute scripts in our origin.

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

diff preview

diff --git a/Cargo.lock b/Cargo.lock
index 3dec7cb72a182dc654a37dca8ba0b49d77504daa..0dd4fce5fb6400ae153cca4e3dbf5a5158e6d8b4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,6 +11,19 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "ammonia"
+version = "4.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "maplit",
+ "tendril",
+ "url",
+]
+
 [[package]]
 name = "anyhow"
 version = "1.0.102"
@@ -355,6 +368,29 @@ version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
 
+[[package]]
+name = "cssparser"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "deranged"
 version = "0.5.8"
@@ -381,6 +417,21 @@ version = "0.15.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
 
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
 [[package]]
 name = "durable"
 version = "0.2.0"
@@ -482,6 +533,16 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
 [[package]]
 name = "futures-channel"
 version = "0.3.32"
@@ -614,6 +675,17 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
+[[package]]
+name = "html5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
+dependencies = [
+ "log",
+ "markup5ever",
+ "match_token",
+]
+
 [[package]]
 name = "http"
 version = "1.4.1"
@@ -974,6 +1046,15 @@ version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
 
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
 [[package]]
 name = "log"
 version = "0.4.30"
@@ -990,6 +1071,40 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
+[[package]]
+name = "markup5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "match_token"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "matchers"
 version = "0.2.0"
@@ -1092,6 +1207,12 @@ dependencies = [
  "tempfile",
 ]
 
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
 [[package]]
 name = "nom"
 version = "7.1.3"
@@ -1175,6 +1296,29 @@ dependencies = [
  "vcpkg",
 ]
 
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
 [[package]]
 name = "peeking_take_while"
 version = "0.1.2"
@@ -1187,6 +1331,58 @@ version = "2.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
 
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
 [[package]]
 name = "pin-project-lite"
 version = "0.2.17"
@@ -1223,6 +1419,12 @@ dependencies = [
  "zerocopy",
 ]
 
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
 [[package]]
 name = "prettyplease"
 version = "0.2.37"
@@ -1379,6 +1581,15 @@ dependencies = [
  "rand_core 0.9.5",
 ]
 
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
 [[package]]
 name = "regex"
 version = "1.12.3"
@@ -1563,6 +1774,12 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
 [[package]]
 name = "security-framework"
 version = "3.7.0"
@@ -1683,6 +1900,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
 [[package]]
 name = "slab"
 version = "0.4.12"
@@ -1709,6 +1932,7 @@ dependencies = [
 name = "sorter2-server"
 version = "0.0.1"
 dependencies = [
+ "ammonia",
  "async-stream",
  "axum",
  "axum-extra",
@@ -1742,6 +1966,31 @@ version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
 
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
 [[package]]
 name = "subtle"
 version = "2.6.1"
@@ -1813,6 +2062,17 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.69"
@@ -2116,6 +2376,12 @@ version = "2.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
 
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
 [[package]]
 name = "utf8_iter"
 version = "1.0.4"
@@ -2281,6 +2547,18 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "web_atoms"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
 [[package]]
 name = "windows-link"
 version = "0.2.1"
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 47659ff82fbfb50972eb2b87575e80f66e572ba4..27f552c20b97ef28cdde4cb6b1a4980375135111 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -13,6 +13,7 @@ serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 thiserror = "1"
 maud = { version = "0.26", features = ["axum"] }
+ammonia = "4.1"
 tower = "0.5"
 tower-http = { version = "0.5", features = ["trace"] }
 tracing = "0.1"
diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs
index dadf050515f0473943dad97df5d318032c8cb385..5b160c6b8bd216dfaf80149854aec0566cd00460 100644
--- a/server/src/fetch/html.rs
+++ b/server/src/fetch/html.rs
@@ -4,6 +4,7 @@ use maud::{html, Markup};
 
 use crate::{
     form_template::template_json_compact,
+    html::sanitize::entity_body_html,
     path_types::ItemId,
     reddit::{is_children_fetchable, is_fetchable},
     reducer::NodeState,
@@ -27,7 +28,7 @@ pub fn entity_panel(node: &NodeState) -> Markup {
                     p class="muted small" { "by " (author) }
                 }
                 @if let Some(body) = &data.body_html {
-                    div class="entity-body" { (maud::PreEscaped(body)) }
+                    div class="entity-body" { (maud::PreEscaped(entity_body_html(body))) }
                 }
             }
         }
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index e180a0ca542a33e2300c0a4809e6b9cfee07ecfe..a58cbbee3490a08a625cb06df06848c59a615d65 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -20,6 +20,7 @@ use crate::{
     ui

… preview truncated; 2,223 characters omitted

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.