constitution · epochs · watch · epoch 3

comparison

c_48edc893c5b0 (tommy-mor) vs c_5e9a63e9d276 (tommy-mor)

download prompt · raw event · cmp_3068c93c412d06

council reasoning

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

B performs a genuine architectural improvement: moving path/URL logic into slug-types as typed newtypes (GardenItemUrl, ForumThreadUrl, TildeOntologyPath), eliminating stringly-typed helpers and duplicated normalization code across server and shared types, with preserved tests. A adds a new /ui endpoint and a JSON-template form-filling mechanism, which is useful but introduces more speculative complexity (a new DSL-like templating system with security caveats) without clear necessity or broad reuse yet. B's refactor reduces duplication and strengthens type safety across the whole API surface, providing more durable value than A's single new feature endpoint.

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

A adds lasting UI architecture: form_template hole-filling, HtmlUiAction, and a unified POST /ui handler that shares web_post run_* paths and enables lazy new-thread morphs—not mere churn. B is valuable (path types moved into slug-types with GardenItemUrl/ForumThreadUrl), but it largely relocates and retypes existing string helpers rather than introducing comparable new product capability.

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

Side B centralizes canonical path normalization and room-aware URL generation into the shared `slug_types::paths` module, replacing scattered string helpers with typed `GardenItemUrl`, `ForumThreadUrl`, and related types across RPCs and API responses. This is a broad architectural improvement that reduces duplication and strengthens type safety, whereas Side A primarily adds a new `/ui` endpoint, HTML UI action parsing, and refactors existing web-post handlers for reuse to support browser interactions.

sides

A — c_48edc893c5b0 (tommy-mor)

message

[3f35edab] progress

diff preview

diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index a10ce662105cff8fad949c6b83f7035ce79bed18..a986f706ea4b261cbaf004c02b4cf84184b41371 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -3,6 +3,7 @@ mod helpers;
 mod rpc;
 mod stream;
 mod validate;
+mod ui_html;
 mod web_post;
 
 pub use auth::{
@@ -33,6 +34,7 @@ pub use stream::{get_html_stream, get_stream};
 
 pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};
 
+pub use ui_html::post_ui_html;
 pub use web_post::{check_web_ingest, post_web_ingest, post_web_redact};
 
 #[cfg(test)]
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2b40a72059981d558768f73d189b991f3448c257
--- /dev/null
+++ b/server/src/api/ui_html.rs
@@ -0,0 +1,139 @@
+//! Single `POST /ui` entry for browser [`crate::html::ui_action::HtmlUiAction`] (JSON in `__rpc__` + holes).
+
+use axum::{
+    body::Body,
+    extract::State,
+    http::{header, HeaderMap, StatusCode},
+    response::{IntoResponse, Response},
+    Form,
+};
+use axum_extra::extract::cookie::CookieJar;
+use std::collections::HashMap;
+
+use crate::{
+    api::{
+        auth::optional_principal,
+        web_post::{run_check_web_ingest, run_post_web_ingest, run_post_web_redact, WebPostForm, WebRedactForm},
+    },
+    html::{
+        fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,
+        parse_html_ui_from_form, user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder,
+        ThreadNav,
+    },
+    state::AppState,
+};
+
+pub async fn post_ui_html(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    jar: CookieJar,
+    Form(form): Form<HashMap<String, String>>,
+) -> impl IntoResponse {
+    let action = match parse_html_ui_from_form(&form) {
+        Ok(a) => a,
+        Err(e) => return ui_js_warn(&e.to_string()).into_response(),
+    };
+
+    match action {
+        HtmlUiAction::PostIngest {
+            room,
+            thread_tag,
+            text,
+            error_target,
+            form_id,
+        } => {
+            run_post_web_ingest(
+                &state,
+                &headers,
+                &jar,
+                WebPostForm {
+                    room,
+                    thread_tag,
+                    text,
+                    error_target,
+                    form_id,
+                },
+            )
+            .await
+        }
+        HtmlUiAction::CheckIngest {
+            room,
+            thread_tag,
+            text,
+            error_target,
+            form_id,
+        } => {
+            run_check_web_ingest(
+                &state,
+                &headers,
+                &jar,
+                WebPostForm {
+                    room,
+                    thread_tag,
+                    text,
+                    error_target,
+                    form_id,
+                },
+            )
+            .await
+        }
+        HtmlUiAction::RedactPost { post_id } => {
+            run_post_web_redact(&state, &headers, &jar, WebRedactForm { post_id }).await
+        }
+        HtmlUiAction::ExpandPublicNewThreadForm => {
+            let reduced = state.reduced.read().await;
+            let user = optional_principal(&headers, &jar, &reduced);
+            drop(reduced);
+            let markup = if user.is_some() {
+                fragment_public_new_thread_form(true)
+            } else {
+                login_to_post_hint_markup()
+            };
+            JsBuilder::new()
+                .morph_selector("#public-new-thread-ui-slot", markup)
+                .into_response()
+        }
+        HtmlUiAction::ExpandRoomNewThreadForm { room_wire } => {
+            let room_wire = room_wire.trim().to_string();
+            if room_wire.is_empty() {
+                return ui_js_warn("missing room").into_response();
+            }
+            let reduced = state.reduced.read().await;
+            let user = optional_principal(&headers, &jar, &reduced);
+            if !reduced.rooms.contains(&room_wire) {
+                drop(reduced);
+                return ui_js_warn("room not found").into_response();
+            }
+            if !user_can_view_room(&reduced, &room_wire, user.as_deref()) {
+                drop(reduced);
+                return ui_js_warn("forbidden").into_response();
+            }
+            let can_post = user
+                .as_ref()
+                .map(|u| user_can_post_room(&reduced, &room_wire, u))
+                .unwrap_or(false);
+            drop(reduced);
+            let Some(nav) = ThreadNav::from_room_id(&room_wire) else {
+                return ui_js_warn("bad room").into_response();
+            };
+            let markup = if can_post {
+                fragment_room_new_thread_form(&nav, true)
+            } else {
+                login_to_post_hint_markup()
+            };
+            JsBuilder::new()
+                .morph_selector("#room-new-thread-ui-slot", markup)
+                .into_response()
+        }
+    }
+}
+
+fn ui_js_warn(msg: &str) -> Response {
+    use crate::html::js_string_literal;
+    let js = format!("console.warn({});", js_string_literal(msg));
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+        .body(Body::from(js))
+        .unwrap()
+}
diff --git a/server/src/api/web_post.rs b/server/src/api/web_post.rs
index a64010e382d3039821c836a5529adad0fe67cce5..265025f41ff1548d05b2d2d5d84202245388053f 100644
--- a/server/src/api/web_post.rs
+++ b/server/src/api/web_post.rs
@@ -222,8 +222,18 @@ pub async fn post_web_redact(
     jar: CookieJar,
     Form(form): Form<WebRedactForm>,
 ) -> impl IntoResponse {
+    run_post_web_redact(&state, &headers, &jar, form).await
+}
+
+/// Shared with [`crate::api::ui_html::post_ui_html`].
+pub(crate) async fn run_post_web_redact(
+    state: &AppState,
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    form: WebRedactForm,
+) -> Response {
     let reduced = state.reduced.read().await;
-    let Some(_username) = optional_principal(&headers, &jar, &reduced) else {
+    let Some(_username) = optional_principal(headers, jar, &reduced) else {
         drop(reduced);
         return js_redirect("/login").into_response();
     };
@@ -239,8 +249,8 @@ pub async fn post_web_redact(
         return js_redirect("/login").into_response();
     };
 
-    match rpc_post_redact(&state, &headers, form.post_id).await {
-        Ok(RpcResult::RedactPostOk {}) => redact_success_response(&state).await.into_response(),
+    match rpc_post_redact(state, headers, form.post_id).await {
+        Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),
         Ok(_) => (StatusCode::BAD_REQUEST, "unexpected response").into_response(),
         Err((msg, hint)) => {
             let detail = hint.as_deref().unwrap_or("");
@@ -255,8 +265,18 @@ pub async fn post_web_ingest(
     jar: CookieJar,
     Form(form): Form<WebPostForm>,
 ) -> impl IntoResponse {
+    run_post_web_ingest(&state, &headers, &jar, form).await
+}
+
+/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).
+pub(crate) async fn run_post_web_ingest(
+    state: &AppState,
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    form: WebPostForm,
+) -> Response {
     let reduced = state.reduced.read().await;
-    let Some(_username) = optional_principal(&headers, &jar, &reduced) else {
+    let Some(_username) = optional_principal(headers, jar, &reduced) else {
         drop(reduced);
         return js_redirect("/login").into_response();
     };
@@ -282,8 +302,8 @@ pub async fn post_web_ingest(
             .into_response();
     }
 
-    match rpc_post_with_bearer(&state, &bearer, room.clone(), thread_tag.clone(), text).await {
-        Ok(RpcResult::PostOk { .. }) => post_success_response(&state, &form, &headers, &jar)
+    match rpc_post_with_bearer(state, &bearer, room.clone(), thread_tag.clone(), text).await {
+        Ok(RpcResult::PostOk { .. }) => post_success_response(state, &form, headers, jar)
             .await
             .into_response(),
         Ok(_) => form_js_error(&form, "unexpected response", "Post did not return PostOk.").into_response(),
@@ -297,8 +317,18 @@ pub async fn check_web_ingest(
     jar: CookieJar,
     Form(form): Form<WebPostForm>,
 ) -> impl IntoResponse {
+    run_check_web_ingest(&state, &headers, &jar, form).await
+}
+
+/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).
+pub(crate) async fn run_check_web_ingest(
+    state: &AppState,
+    headers: &HeaderMap,
+    jar: &CookieJar,
+    form: WebPostForm,
+) -> Response {
     let reduced = state.reduced.read().await;
-    let Some(_username) = optional_principal(&headers, &jar, &reduced) else {
+    let Some(_username) = optional_principal(headers, jar, &reduced) else {
         drop(reduced);
         return js_redirect("/login").into_response();
     };
@@ -324,7 +354,7 @@ pub async fn check_web_ingest(
         return js_clear_errors(&form_error_target(&form)).into_response();
     }
 
-    match rpc_check_with_bearer(&state, &bearer, room, form.text.clone()).await {
+    match rpc_check_with_bearer(state, &bearer, room, form.text.clone()).await {
         Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(&form)).into_response(),
         Ok(_) => form_js_error(&form, "unexpected response", "Check did not return CheckOk.").into_response(),
         Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or("")).into_response(),
diff --git a/server/src/form_template.rs b/server/src/form_template.rs
new file mode 100644
index 0000000000000000000000000000000000000000..3709c2c09a859da006e4af173413d5d235bc19be
--- /dev/null
+++ b/server/src/form_template.rs
@@ -0,0 +1,142 @@
+//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from
+//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**
+//! deserializing into a typed struct.
+//!
+//! # Wire format
+//!
+//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty
+//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string
+//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not
+//! bespoke encodings.
+//!
+//! # Power vs flat hidden fields
+//!
+//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),
+//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,
+//! arrays, and optional fields without inventing a new naming scheme each time.
+//!
+//! # Security
+//!
+//! Substitution runs **before** `serde` into your command type. It does not fix
+//! authorization: if the client can replace the hidden `__rpc__` value, they can
+//! change the command shape unless you validate (signed blob, server-side session
+//! context, or treat the blob as hints only). Same threat model as any hidden field.
+
+use serde::Serialize;
+use serde_json::Value;
+use std::collections::HashMap;
+
+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.
+pub fn template_json_compact<T: Serialize>(v: &T) -> serde_json::Result<String> {
+    serde_json::to_string(v)
+}
+
+/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted
+/// string for `key` (empty if missing). Other keys are unchanged.
+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap<String, String>) {
+    match val {
+        Value::Object(map) => {
+            if map.len() == 1 {
+                if let Some(Value::String(field_name)) = map.get("$form") {
+                    let submitted = form_data
+                        .get(field_name.as_str())
+                        .map(|s| s.as_str())
+                        .unwrap_or("");
+   

… preview truncated; 13,273 characters omitted

download full diff A

B — c_5e9a63e9d276 (tommy-mor)

message

[a888d56c] refactor: centralize path identity in slug-types

Move canonicalization and CanonicalItemUrl into types::paths with
GardenItemUrl, ForumThreadUrl, and TildeOntologyPath for JSON hrefs.
Server canonical_path and path_types re-export slug-types; RPC and
validation build hrefs via those types instead of string helpers.

Made-with: Cursor

diff preview

diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs
index 9b71491e9f9efc44a2a4beba09be8f64bd2ff2ee..03b3e77911ccd662bec8635345dafe2593cf242e 100644
--- a/server/src/api/helpers.rs
+++ b/server/src/api/helpers.rs
@@ -4,12 +4,12 @@ use axum::{
     Json,
 };
 use sha2::{Digest, Sha256};
+use slug_types::paths::{CanonicalItemUrl, GardenItemUrl};
 use slug_types::*;
 use std::collections::HashMap;
 
 use crate::{
     canonical_path::canonicalize_item,
-    path_types::CanonicalItemUrl,
     ranking::connected_components_from_voted_pairs,
 };
 
@@ -30,64 +30,6 @@ pub fn now_ms() -> i64 {
     t.as_millis() as i64
 }
 
-/// Serialize a canonical item for JSON: absolute URLs stay as-is; bare paths get a `/` prefix.
-pub fn item_path_for_api(item: &str) -> String {
-    if item.starts_with("http://") || item.starts_with("https://") {
-        item.to_string()
-    } else {
-        format!("/{}", item)
-    }
-}
-
-/// Same as [`item_path_for_api`], but for private rooms ontology items are prefixed with
-/// `/r/{short}/{slug}` so the URL matches the web app (`/r/…/~/…` routes).
-pub fn item_path_for_api_in_room(item: &str, room_wire: &str) -> String {
-    let room = room_wire.trim();
-    if room.is_empty() || room == "public" {
-        return item_path_for_api(item);
-    }
-    let Some((short, slug)) = room.split_once('/') else {
-        return item_path_for_api(item);
-    };
-    if short.is_empty() || slug.is_empty() {
-        return item_path_for_api(item);
-    }
-    let Some(c) = CanonicalItemUrl::parse(item) else {
-        return item_path_for_api(item);
-    };
-    let root = CanonicalItemUrl::ontology_root();
-    let item_norm = c.as_str().trim_end_matches('/');
-    let root_norm = root.as_str().trim_end_matches('/');
-    if let Some(tail) = c.tilde_tail() {
-        return if tail.is_empty() {
-            format!("https://slug.social/r/{short}/{slug}/~")
-        } else {
-            format!("https://slug.social/r/{short}/{slug}/~/{}", tail)
-        };
-    }
-    if item_norm == root_norm {
-        return format!("https://slug.social/r/{short}/{slug}/~");
-    }
-    item_path_for_api(item)
-}
-
-/// Absolute thread URL for forum JSON (`/t/…` vs `/r/…/t/…`).
-pub fn forum_thread_web_url(room_wire: &str, thread_tag: &str) -> String {
-    let room = room_wire.trim();
-    let tag = thread_tag.trim().trim_start_matches('#');
-    if room.is_empty() || room == "public" {
-        format!("https://slug.social/t/{tag}")
-    } else if let Some((short, slug)) = room.split_once('/') {
-        if short.is_empty() || slug.is_empty() {
-            format!("https://slug.social/t/{tag}")
-        } else {
-            format!("https://slug.social/r/{short}/{slug}/t/{tag}")
-        }
-    } else {
-        format!("https://slug.social/t/{tag}")
-    }
-}
-
 /// Resolve an item path as a first-class canonical path.
 pub fn resolve_item(item: &str) -> Result<String, String> {
     let canonical = canonicalize_item(item);
@@ -109,14 +51,12 @@ pub fn parse_parent_specs(parent: Option<&String>) -> Vec<String> {
 }
 
 /// Apply offset+limit pagination to the flattened component rankings.
-/// Items are flattened in component order (largest component first), then unranked last.
-/// Returns (components, unranked_items) after the window.
 pub fn paginate_rankings(
     components: Vec<RankComponent>,
-    unranked_items: Vec<String>,
+    unranked_items: Vec<GardenItemUrl>,
     offset: usize,
     limit: Option<usize>,
-) -> (Vec<RankComponent>, Vec<String>) {
+) -> (Vec<RankComponent>, Vec<GardenItemUrl>) {
     let mut remaining_skip = offset;
     let mut remaining_take = limit.unwrap_or(usize::MAX);
     let mut out_components: Vec<RankComponent> = Vec::new();
@@ -141,7 +81,7 @@ pub fn paginate_rankings(
         });
     }
 
-    let out_unranked: Vec<String> = if remaining_take > 0 {
+    let out_unranked: Vec<GardenItemUrl> = if remaining_take > 0 {
         unranked_items
             .into_iter()
             .skip(remaining_skip)
@@ -183,11 +123,9 @@ pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bo
     group.voted_pairs.contains(&(i, j))
 }
 
-/// Compute graph connectivity stats for a set of items within the ranking group.
 pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[String]) -> ConnectivityStats {
     let n = pool.len();
 
-    // Map pool items to global indices (items not yet in the group get no index)
     let global_idxs: Vec<Option<usize>> = pool
         .iter()
         .map(|it| {
@@ -197,7 +135,6 @@ pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[St
         .collect();
     let present: Vec<usize> = global_idxs.iter().filter_map(|x| *x).collect();
 
-    // Build local index mapping for items that exist in the ranking group
     let global_to_local: HashMap<usize, usize> = present
         .iter()
         .enumerate()
@@ -213,7 +150,6 @@ pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[St
         }),
     );
 
-    // Items not in the ranking group at all are also isolates
     let items_not_in_group = global_idxs.iter().filter(|x| x.is_none()).count();
 
     let num_components = comps.len() + isolates.len() + items_not_in_group;
@@ -237,52 +173,3 @@ pub fn vote_touches_path(a: &str, b: &str, parent_canon: &str) -> bool {
     let under = |item: &str| item == parent_canon || item.starts_with(&format!("{}/", parent_canon));
     under(a) || under(b)
 }
-
-#[cfg(test)]
-mod wire_url_tests {
-    use super::{forum_thread_web_url, item_path_for_api_in_room};
-
-    #[test]
-    fn public_room_unchanged() {
-        let u = "https://slug.social/~/a/b";
-        assert_eq!(item_path_for_api_in_room(u, "public"), u);
-    }
-
-    #[test]
-    fn private_room_prefixes_ontology() {
-        assert_eq!(
-            item_path_for_api_in_room("https://slug.social/~/topic/x", "9ab12cd/my-room"),
-            "https://slug.social/r/9ab12cd/my-room/~/topic/x"
-        );
-    }
-
-    #[test]
-    fn private_room_ontology_root() {
-        assert_eq!(
-            item_path_for_api_in_room("https://slug.social/~", "9ab12cd/my-room"),
-            "https://slug.social/r/9ab12cd/my-room/~"
-        );
-        assert_eq!(
-            item_path_for_api_in_room("https://slug.social/~/", "9ab12cd/my-room"),
-            "https://slug.social/r/9ab12cd/my-room/~"
-        );
-    }
-
-    #[test]
-    fn external_url_untouched_in_private_room() {
-        let u = "https://example.com/z";
-        assert_eq!(item_path_for_api_in_room(u, "9ab12cd/my-room"), u);
-    }
-
-    #[test]
-    fn forum_web_public_vs_room() {
-        assert_eq!(
-            forum_thread_web_url("public", "debate"),
-            "https://slug.social/t/debate"
-        );
-        assert_eq!(
-            forum_thread_web_url("9ab12cd/my-room", "#debate"),
-            "https://slug.social/r/9ab12cd/my-room/t/debate"
-        );
-    }
-}
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 042aa248305f9362a3be78f9eea2a5abf6ba707a..cf22cb0129366c3aed031bc86f3197a4321cb806 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -24,8 +24,7 @@ pub use auth::{
 
 pub use helpers::{
     api_error, compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings,
-    parse_parent_specs, pick_random_distinct, sha256_hex, resolve_item, vote_touches_path,
-    item_path_for_api,
+    parse_parent_specs, pick_random_distinct, resolve_item, sha256_hex, vote_touches_path,
 };
 
 pub use rpc::handle_rpc_batch;
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 5b91f5836625eedbb1cd9423168046e3fb576c17..5f7d50188f1381267402f2e57e671234ef5db2fd 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -8,6 +8,7 @@ use axum::{
     Json,
 };
 use rand::seq::SliceRandom;
+use slug_types::paths::{ForumThreadUrl, GardenItemUrl, TildeOntologyPath};
 use slug_types::*;
 
 use crate::{
@@ -27,9 +28,8 @@ use crate::{
 
 use super::auth::verify_bearer_principal;
 use super::helpers::{
-    compute_connectivity_stats, forum_thread_web_url, is_pair_voted, item_path_for_api,
-    item_path_for_api_in_room, now_ms, paginate_rankings, parse_parent_specs, pick_random_distinct,
-    resolve_item, vote_touches_path,
+    compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs,
+    pick_random_distinct, resolve_item, vote_touches_path,
 };
 use super::validate::{normalize_room_and_thread, validate_ingest_document};
 
@@ -184,7 +184,7 @@ fn compute_scope_rank_changes(
         };
         if changed {
             changes.push(RankChange {
-                item: item_path_for_api_in_room(&item, room_wire),
+                item: GardenItemUrl::from_storage_str(&item, room_wire),
                 before: b,
                 after: a,
             });
@@ -206,7 +206,7 @@ fn compute_scope_rank_changes(
         parent: if parent.is_empty() {
             "/".to_string()
         } else {
-            item_path_for_api_in_room(parent, room_wire)
+            GardenItemUrl::from_storage_str(parent, room_wire).into_inner()
         },
         changes,
     })
@@ -302,7 +302,7 @@ fn build_rank_response_for_content(
                     .ranked
                     .into_iter()
                     .map(|r| RankRow {
-                        item: item_path_for_api_in_room(r.item.as_str(), room_wire),
+                        item: GardenItemUrl::from_stored(&r.item, room_wire),
                         percent: if want_percent {
                             Some((r.score / max_score) * 100.0)
                         } else {
@@ -315,10 +315,10 @@ fn build_rank_response_for_content(
         })
         .collect();
 
-    let prefixed_unranked: Vec<String> = rankings
+    let prefixed_unranked: Vec<GardenItemUrl> = rankings
         .unranked_items
         .into_iter()
-        .map(|s| item_path_for_api_in_room(s.as_str(), room_wire))
+        .map(|s| GardenItemUrl::from_stored(&s, room_wire))
         .collect();
 
     let (components, unranked_items) = if offset > 0 || limit.is_some() {
@@ -537,13 +537,13 @@ async fn rpc_post(
         (
             "npx slugsocial public garden pair".to_string(),
             "npx slugsocial public garden rank".to_string(),
-            forum_thread_web_url("public", &thread_id),
+            ForumThreadUrl::from_room_tag("public", &thread_id),
         )
     } else {
         (
             format!("npx slugsocial private {room_key} garden pair"),
             format!("npx slugsocial private {room_key} garden rank"),
-            forum_thread_web_url(&room_key, &thread_id),
+            ForumThreadUrl::from_room_tag(&room_key, &thread_id),
         )
     };
 
@@ -664,7 +664,7 @@ async fn rpc_check(
                         .ranked
                         .into_iter()
                         .map(|r| RankRow {
-                            item: item_path_for_api_in_room(r.item.as_str(), &room_key),
+                            item: GardenItemUrl::from_stored(&r.item, &room_key),
                             score: r.score,
                             percent: None,
                         })
@@ -672,12 +672,12 @@ async fn rpc_check(
                 })
                 .collect();
             CheckScopeRanking {
-                parent: item_path_for_api_in_room(parent.as_str(), &room_key),
+                parent: GardenItemUrl::from_stored(parent, &room_key).into_inner(),
                 components,
                 unranked_items: scoped
                     .unranked_items
                     .into_iter()
-                    .map(|it| item_path_for_api_in_room(it.as_str(), &room_key))
+                    .map(|it| GardenItemUrl::from_stored(&it, &room_key))
                     .collect(),
             }
         })
@@ -687,13 +687,13 @@ async fn rpc_check(
         vec![
             "npx slugsocial public forum post <TAG> --delegate <uuid:rig:

… preview truncated; 46,249 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.