constitution · epochs · watch · epoch 3

comparison

c_3f420a1f5aa1 (tommy-mor) vs c_e57094c6229a (tommy-mor)

download prompt · raw event · cmp_a42e07758c8d2f

council reasoning

~anthropic/claude-sonnet-latest · winner B · 55:45 · permalink

Side B introduces a real architectural improvement: raw entity payloads are now persisted through the event log (EntityImported event) and replayed on startup, fetches become explicit user actions instead of implicit auto-fetch, and it adds config (dotenv, configurable API/OAuth bases) plus tests and fixtures validating replay/import correctness. Side A is a broad but mostly mechanical refactor (threading theme/jar/uri params through many handlers, room-prefixed URL helpers) which is useful but lower-risk plumbing with less architectural novelty, though it does add solid test coverage for URL prefixing.

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

B lands a durable domain design: EntityImported in the event log with raw payload + derived view, explicit user-driven fetch (no auto-fetch on browse), configurable API bases, replay, and an end-to-end mock integration test. A’s room-aware wire URLs (item_path_for_api_in_room / forum_thread_web_url) are real API correctness, but much of the diff is theme plumbing (cookie/layout/handler threading) that is less foundational than B’s import architecture.

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

Side A implements two broadly useful, user-facing capabilities across the codebase: persistent theme selection via validated cookies and a dedicated /theme endpoint (including preserving the theme across login redirects), plus correct room-aware URL generation for RPC/API responses with new helper functions and tests. Side B adds explicit Reddit entity fetching with event-log persistence of raw payloads and replay support, which is substantial, but it is more specialized; Side A's changes affect core navigation, authentication, rendering, and link correctness throughout the application.

sides

A — c_3f420a1f5aa1 (tommy-mor)

message

[2fe70b0e] themes

diff preview

diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 559db65de14c6157690ffbf18eca0cf65b0a5202..b3631b06153d52f88348fef927e7a024b7b85ad6 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -1,7 +1,7 @@
 use axum::{
     body::Body,
     extract::{Path, Query, State},
-    http::{header, HeaderMap, HeaderValue, StatusCode},
+    http::{header, HeaderMap, HeaderValue, StatusCode, Uri},
     response::{IntoResponse, Redirect, Response},
     Form, Json,
 };
@@ -17,7 +17,7 @@ use crate::{
     events::{Event, GrantAdded, TokenIssued, UserRegistered},
     html::{
         auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,
-        choose_username_page, JsBuilder,
+        choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder,
     },
     identity::{parse_agent, parse_username},
     reducer::ReducerState,
@@ -48,15 +48,17 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response {
         .into_response()
 }
 
-fn js_signed_in_fragment(bearer: &str) -> Response {
+fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {
     let mut response = JsBuilder::new()
         .id("choose-username-form")
         .morph_inner(auth_signed_in_fragment())
         .redirect("/auth/complete")
         .into_response();
-    response
-        .headers_mut()
-        .insert(header::SET_COOKIE, session_cookie_header_value(bearer));
+    let headers = response.headers_mut();
+    headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));
+    if let Some(theme) = theme_cookie_header_from_jar(jar) {
+        headers.append(header::SET_COOKIE, theme);
+    }
     response
 }
 
@@ -69,13 +71,18 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce
     verify_token(reduced, c.value()).ok()
 }
 
-fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str) -> Response {
-    Response::builder()
+fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {
+    let mut res = Response::builder()
         .status(StatusCode::TEMPORARY_REDIRECT)
         .header(header::LOCATION, format!("{public_url}{path_and_query}"))
-        .header(header::SET_COOKIE, session_cookie_header_value(bearer))
         .body(Body::empty())
-        .unwrap()
+        .unwrap();
+    let headers = res.headers_mut();
+    headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));
+    if let Some(theme) = theme_cookie_header_from_jar(jar) {
+        headers.append(header::SET_COOKIE, theme);
+    }
+    res
 }
 
 async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {
@@ -286,7 +293,11 @@ pub struct AuthCallbackQuery {
     pub state: String,
 }
 
-pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state): State<AppState>) -> impl IntoResponse {
+pub async fn get_auth_callback(
+    Query(q): Query<AuthCallbackQuery>,
+    State(state): State<AppState>,
+    jar: CookieJar,
+) -> impl IntoResponse {
     let sessions = pending_sessions(&state);
     {
         let sessions_read = sessions.read().await;
@@ -365,7 +376,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
             }
             let cookie_bearer = bearer.clone();
             s.complete = Some((username, bearer));
-            return redirect_with_session_cookie(&public_url, "/", &cookie_bearer).into_response();
+            return redirect_with_session_cookie(&public_url, "/", &cookie_bearer, &jar).into_response();
         }
     }
 
@@ -378,14 +389,20 @@ pub struct ChooseUsernameQuery {
     pub error: Option<String>,
 }
 
-pub async fn get_choose_username(Query(q): Query<ChooseUsernameQuery>, State(state): State<AppState>) -> impl IntoResponse {
+pub async fn get_choose_username(
+    Query(q): Query<ChooseUsernameQuery>,
+    State(state): State<AppState>,
+    jar: CookieJar,
+    uri: Uri,
+) -> impl IntoResponse {
     let sessions = pending_sessions(&state);
     let sessions_read = sessions.read().await;
     if !sessions_read.contains_key(&q.session) {
         return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
     }
     drop(sessions_read);
-    choose_username_page(&q.session, q.error.as_deref()).into_response()
+    let next = theme_next_from_uri(&uri);
+    choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next).into_response()
 }
 
 #[derive(Debug, Deserialize)]
@@ -396,6 +413,7 @@ pub struct ChooseUsernameForm {
 
 pub async fn post_choose_username(
     State(state): State<AppState>,
+    jar: CookieJar,
     Form(form): Form<ChooseUsernameForm>,
 ) -> impl IntoResponse {
     let canon_user = match parse_username(&form.username) {
@@ -477,7 +495,7 @@ pub async fn post_choose_username(
         s.complete = Some((canon_user.clone(), bearer.clone()));
     }
 
-    js_signed_in_fragment(&bearer).into_response()
+    js_signed_in_fragment(&bearer, &jar).into_response()
 }
 
 /// Start a browser-only OAuth flow (no CLI polling). Sets session cookie on success.
@@ -569,8 +587,9 @@ pub async fn get_pending_session(
     .into_response()
 }
 
-pub async fn get_auth_complete() -> impl IntoResponse {
-    auth_complete_page()
+pub async fn get_auth_complete(jar: CookieJar, uri: Uri) -> impl IntoResponse {
+    let next = theme_next_from_uri(&uri);
+    auth_complete_page(theme_from_jar(&jar), &next).into_response()
 }
 
 pub async fn get_whoami(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs
index 81e2a55fa3abb8609b4099f91a989480336e11eb..9b71491e9f9efc44a2a4beba09be8f64bd2ff2ee 100644
--- a/server/src/api/helpers.rs
+++ b/server/src/api/helpers.rs
@@ -39,6 +39,55 @@ pub fn item_path_for_api(item: &str) -> String {
     }
 }
 
+/// 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);
@@ -188,3 +237,52 @@ 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/rpc.rs b/server/src/api/rpc.rs
index 31f5fcfb4eaf4df0a9cbac532dd3dedfe3611810..5b91f5836625eedbb1cd9423168046e3fb576c17 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -27,8 +27,9 @@ use crate::{
 
 use super::auth::verify_bearer_principal;
 use super::helpers::{
-    compute_connectivity_stats, is_pair_voted, item_path_for_api, now_ms, paginate_rankings,
-    parse_parent_specs, pick_random_distinct, resolve_item, vote_touches_path,
+    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,
 };
 use super::validate::{normalize_room_and_thread, validate_ingest_document};
 
@@ -148,6 +149,7 @@ fn compute_scope_rank_changes(
     parent: &str,
     before: &crate::scope_rank::ChildrenRankings,
     after: &crate::scope_rank::ChildrenRankings,
+    room_wire: &str,
 ) -> Option<ScopeRankChanges> {
     fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap<String, Option<RankPosition>> {
         let mut map = HashMap::new();
@@ -182,7 +184,7 @@ fn compute_scope_rank_changes(
         };
         if changed {
             changes.push(RankChange {
-                item: item_path_for_api(&item),
+                item: item_path_for_api_in_room(&item, room_wire),
                 before: b,
                 after: a,
             });
@@ -204,7 +206,7 @@ fn compute_scope_rank_changes(
         parent: if parent.is_empty() {
             "/".to_string()
         } else {
-            item_path_for_api(parent)
+            item_path_for_api_in_room(parent, room_wire)
         },
         changes,
     })
@@ -256,6 +258,7 @@ fn build_rank_response_for_content(
     offset: usize,
     limit: Option<usize>,
     want_percent: bool,
+    room_wire: &str,
 ) -> Result<RankResponse, RpcErr> {
     let parent_owned = parent.map(|s| s.to_string());
     let specs = parse_parent_specs(parent_owned.as_ref());
@@ -299,7 +302,7 @@ fn build_rank_response_for_content(
                     .ranked
                     .into_iter()
                     .map(|r| RankRow {
-                  

… preview truncated; 42,928 characters omitted

download full diff A

B — c_e57094c6229a (tommy-mor)

message

[40b975bf] nice

diff preview

diff --git a/Cargo.lock b/Cargo.lock
index 266e876bb7ccbe788beb1d5bd53ad5b45ee5825b..2cea973082716e761ef6f5dd5886acc08ff9aac0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -222,6 +222,12 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
 [[package]]
 name = "encoding_rs"
 version = "0.8.35"
@@ -1238,6 +1244,7 @@ version = "0.0.1"
 dependencies = [
  "axum",
  "axum-extra",
+ "dotenvy",
  "maud",
  "reqwest",
  "serde",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 4677fedcb45292eebebe7e9cf6ce2f5738f18ddf..bd600138b613bd0f546bdec217a5334cdcb20aa5 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -17,6 +17,7 @@ tower-http = { version = "0.5", features = ["trace"] }
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
 reqwest = { version = "0.12", features = ["json"] }
+dotenvy = "0.15"
 
 [dev-dependencies]
 reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index d2024bd4582bcc8482b461b2ba4fedbd8bff7c66..b33a84e8bb5e817b26592868d88090e6d664d950 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -6,7 +6,7 @@ use axum::{
 use std::collections::HashMap;
 
 use crate::{
-    html::{input_panel, js_string_literal, ranking_panel, JsBuilder},
+    html::{entity_section, input_panel, js_string_literal, ranking_panel, JsBuilder},
     parser::parse_reddit_url,
     path_types::ItemId,
     reddit::ensure_partial_tree,
@@ -87,6 +87,20 @@ pub async fn post_ui_html(
                     .into_response()
             }
         },
+        HtmlUiAction::FetchEntity { item } => {
+            let id = parse_item_param(&item);
+            if id.is_root() {
+                return ui_js_warn("nothing to fetch for the root").into_response();
+            }
+            state.queue_entity_fetch(id.clone());
+            let tree = state.tree.read().await;
+            let empty = crate::reducer::NodeState::default();
+            let node = tree.get(&id).unwrap_or(&empty);
+            let panel = entity_section(&id, node, true);
+            JsBuilder::new()
+                .morph_selector("#entity-section", panel)
+                .into_response()
+        },
     }
 }
 
diff --git a/server/src/events.rs b/server/src/events.rs
index ed5be6b13b9d46e838831d6ce0f96f569b401730..07ce24b5e56cf72b0b442c3c3241efbf6c3b006a 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -1,4 +1,5 @@
 use serde::{Deserialize, Serialize};
+use serde_json::Value;
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 #[serde(tag = "type", rename_all = "snake_case")]
@@ -18,4 +19,10 @@ pub enum Event {
     },
     /// Register a node path in the fractal tree (no external fetch).
     NodeEnsured { id: String },
+    /// Full upstream API payload for a node (domain-specific view derived at replay/render time).
+    EntityImported {
+        id: String,
+        ts: i64,
+        payload: Value,
+    },
 }
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index df6505021d9f446c2b453e20e3eb3cf696a111f9..caf1309c8d93b47104499c57f9cc35ee7631fbb9 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -10,6 +10,7 @@ use crate::{
     form_template::template_json_compact,
     path_types::ItemId,
     ranking::{top_bottom, RankedItem},
+    reddit::is_fetchable,
     reducer::{GroupState, NodeState},
     state::AppState,
     ui_action::UI_RPC_FIELD,
@@ -151,7 +152,7 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {
 fn entity_panel(node: &NodeState) -> Markup {
     html! {
         @if let Some(data) = &node.data {
-            section id="entity-panel" class="demo-panel entity-card" {
+            div id="entity-panel" class="entity-card" {
                 h2 { (data.title) }
                 @if let Some(author) = &data.author {
                     p class="muted small" { "by " (author) }
@@ -164,6 +165,42 @@ fn entity_panel(node: &NodeState) -> Markup {
     }
 }
 
+/// Reddit/API import control — only shown on fetchable pages; never auto-fires.
+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
+    if !is_fetchable(item) {
+        return html! {};
+    }
+    let label = if fetching {
+        "Fetching…"
+    } else if has_data {
+        "Fetch more"
+    } else {
+        "Fetch from Reddit"
+    };
+    let rpc = template_json_compact(&serde_json::json!({
+        "action": "fetch_entity",
+        "item": item.as_str(),
+    }))
+    .expect("fetch_entity rpc template");
+    html! {
+        form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
+            input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
+            button type="submit" class="btn-secondary" disabled=(fetching) { (label) }
+        }
+    }
+}
+
+/// Entity card + explicit fetch control (morphed as `#entity-section`).
+pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {
+    let has_data = node.data.is_some();
+    html! {
+        section id="entity-section" class="demo-panel" {
+            (entity_panel(node))
+            (fetch_entity_panel(item, has_data, fetching))
+        }
+    }
+}
+
 fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
     html! {
         @if !items.is_empty() {
@@ -260,7 +297,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {
         h1 { "sorter" }
         (input_panel("", None))
         (breadcrumb_path(&item))
-        (entity_panel(node))
+        (entity_section(&item, node, false))
         (ranking_panel(&item, group))
     };
     layout("sorter2", body, views)
@@ -272,16 +309,5 @@ pub async fn home(State(state): State<AppState>, uri: Uri) -> impl IntoResponse
 
 pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoResponse {
     let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());
-    if item.as_str().starts_with("reddit.com") {
-        let needs_fetch = {
-            let tree = state.tree.read().await;
-            tree.get(&item)
-                .map(|n| n.data.is_none())
-                .unwrap_or(true)
-        };
-        if needs_fetch {
-            state.reddit.request_fetch(item.clone());
-        }
-    }
     item_page(state, uri, item).await
 }
diff --git a/server/src/main.rs b/server/src/main.rs
index c22ec6c9f5358e5ec99fb83210dc351938505a93..1f0cddc39302b35b0cd6a6219f44c9d59202facf 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -2,6 +2,10 @@ use sorter2_server::state::AppConfig;
 
 #[tokio::main]
 async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+    if std::env::var("SORTER2_SKIP_DOTENV").is_err() {
+        let _ = dotenvy::dotenv();
+    }
+
     tracing_subscriber::fmt()
         .with_env_filter(
             tracing_subscriber::EnvFilter::try_from_default_env()
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index 90053ad03b1d7c8e94f325dd4ee64c2b4f7da900..ff0f01e57b18af878eb5be3efc47204a7673589d 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -6,11 +6,15 @@ use std::time::{Duration, Instant};
 
 use reqwest::{header, Client, StatusCode};
 use serde::Deserialize;
+use serde_json::Value;
 use tokio::sync::{mpsc, RwLock};
 
 use crate::{
+    event_log::EventLog,
+    events::Event,
+    html::now_ms,
     path_types::ItemId,
-    reducer::{EntityData, GlobalTree},
+    reducer::GlobalTree,
 };
 
 /// Bootstrap blank nodes along a URL path so breadcrumbs and voting work before fetch.
@@ -20,6 +24,8 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {
 
 pub struct RedditCommand {
     pub id: ItemId,
+    /// User-initiated fetch bypasses the in-memory "recently fetched" cache.
+    pub force: bool,
 }
 
 #[derive(Clone)]
@@ -33,19 +39,31 @@ struct RedditCredentials {
     client_secret: String,
 }
 
+#[derive(Clone)]
+pub struct RedditApiConfig {
+    pub api_base: String,
+    pub oauth_base: String,
+    pub user_agent: String,
+    creds: Option<RedditCredentials>,
+}
+
 struct OAuthToken {
     access_token: String,
     expires_at: Instant,
 }
 
 impl RedditBroker {
-    pub fn spawn(tree: Arc<RwLock<GlobalTree>>, user_agent: &str) -> Self {
+    pub fn spawn(
+        tree: Arc<RwLock<GlobalTree>>,
+        event_log: Arc<EventLog>,
+        config: RedditApiConfig,
+    ) -> Self {
         let (tx, rx) = mpsc::channel(100);
 
         let mut headers = header::HeaderMap::new();
         headers.insert(
             header::USER_AGENT,
-            header::HeaderValue::from_str(user_agent).expect("valid user agent"),
+            header::HeaderValue::from_str(&config.user_agent).expect("valid user agent"),
         );
 
         let client = Client::builder()
@@ -54,22 +72,38 @@ impl RedditBroker {
             .build()
             .expect("reqwest client");
 
-        let creds = RedditCredentials::from_env();
-        tokio::spawn(reddit_worker(rx, tree, client, creds));
+        tokio::spawn(reddit_worker(rx, tree, event_log, client, config));
 
         Self { tx }
     }
 
-    /// Fire-and-forget: queue a fetch; worker updates the tree when done.
-    pub fn request_fetch(&self, id: ItemId) {
-        let _ = self.tx.try_send(RedditCommand { id });
+    /// Queue a fetch; drops when the channel is full (backpressure).
+    pub fn request_fetch(&self, id: ItemId, force: bool) {
+        let _ = self.tx.try_send(RedditCommand { id, force });
+    }
+}
+
+impl RedditApiConfig {
+    pub fn from_env() -> Self {
+        Self {
+            api_base: reddit_api_base(),
+            oauth_base: reddit_oauth_base(),
+            user_agent: default_user_agent(),
+            creds: RedditCredentials::from_env(),
+        }
     }
 }
 
 impl RedditCredentials {
+    /// Reddit's OAuth docs call these "client id" and "client secret"; the app
+    /// registration UI often labels them "app id" / "app secret" — same values.
     fn from_env() -> Option<Self> {
-        let client_id = std::env::var("REDDIT_CLIENT_ID").ok()?;
-        let client_secret = std::env::var("REDDIT_CLIENT_SECRET").ok()?;
+        let client_id = std::env::var("REDDIT_CLIENT_ID")
+            .or_else(|_| std::env::var("REDDIT_APP_ID"))
+            .ok()?;
+        let client_secret = std::env::var("REDDIT_CLIENT_SECRET")
+            .or_else(|_| std::env::var("REDDIT_APP_SECRET"))
+            .ok()?;
         if client_id.is_empty() || client_secret.is_empty() {
             return None;
         }
@@ -80,29 +114,63 @@ impl RedditCredentials {
     }
 }
 
+pub fn reddit_api_base() -> String {
+    std::env::var("REDDIT_API_BASE").unwrap_or_else(|_| "https://www.reddit.com".into())
+}
+
+pub fn reddit_oauth_base() -> String {
+    std::env::var("REDDIT_OAUTH_BASE").unwrap_or_else(|_| "https://www.reddit.com".into())
+}
+
 pub fn default_user_agent() -> String {
     std::env::var("REDDIT_USER_AGENT").unwrap_or_else(|_| {
         "web:sorter2.social:v0.0.1 (by /u/sorter2)".to_string()
     })
 }
 
+/// True when this node can be loaded from the Reddit JSON API.
+pub fn is_fetchable(id: &ItemId) -> bool {
+    !map_item_to_reddit_api(id, "https://example.com").is_empty()
+}
+
+/// Derive UI-facing fields from a stored payload (Reddit-specific when under reddit.com).
+pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option<crate::reducer::EntityData> {
+    if id.as_str().starts_with("reddit.com") {
+        return parse_reddit_view(id, payload);
+    }
+    None
+}
+
+/// Apply a full API payload to the in-memory tree (view derived for known domains).
+pub fn apply_entity_import(tree: &mut GlobalTree, id: &ItemId, payload: Value) {
+    let view = entity_view_from_payload(id, &payload);
+    tree.apply_entity_raw(id, payload, view);
+}
+
 async fn red

… preview truncated; 22,673 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.