constitution · epochs · watch · epoch 3

comparison

c_2595b6007624 (tommy-mor) vs c_552f408ae0da (tommy-mor)

download prompt · raw event · cmp_0037ff72e07de1

council reasoning

~anthropic/claude-sonnet-latest · winner A · 8:2 · permalink

Side A is a substantial architectural migration (converting the entire REST API surface to a batched RPC model, room/thread scoping refactor, reducer state redesign, and extensive test rewrites) that establishes the foundation the rest of the codebase builds on. Side B is a small incremental feature (RoomList command) built entirely on top of A's RPC infrastructure, useful but narrow in scope and lasting impact by comparison.

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

Commit A is a foundational redesign: REST endpoints collapse into a batch RPC API, events/reducer split permission boundaries (rooms) from forum channels (thread tags), and the CLI gains public/private scoping— lasting structural value across server, types, tests, and client. Commit B only adds a RoomList RPC + CLI subcommand with isolation tests, a useful but narrow feature that sits on top of that architecture.

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

Side A introduces a new batched RPC architecture (`/api/v0/rpc`), refactors the CLI to use typed RPC commands, separates room and forum concepts, migrates event and reducer data from thread IDs to room/thread metadata, and centralizes validation into reusable modules. Side B is a useful incremental feature—adding a `RoomList` RPC command, CLI subcommand, and integration tests for per-user room visibility—but it builds on the infrastructure established by Side A rather than changing the project's core design.

sides

A — c_2595b6007624 (tommy-mor)

message

[96b6da05] rpc + reducer changes first pass

diff preview

diff --git a/cli/src/main.rs b/cli/src/main.rs
index 630c5dea1f78c0ec9bc53e6b96234a0dc75bb705..8d0442959f4332bafe499a2a8cdf364731a06871 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -21,8 +21,9 @@ struct Cli {
     cmd: Option<Command>,
 }
 
+/// Commands scoped to a room (`public` or `shortid/slug`).
 #[derive(Subcommand, Debug)]
-enum Command {
+enum ScopedCmd {
     /// Browse the garden (ontology) — light mode, ranked by votes
     Garden {
         #[command(subcommand)]
@@ -174,6 +175,23 @@ enum Command {
         #[arg(long)]
         json: bool,
     },
+}
+
+#[derive(Subcommand, Debug)]
+enum Command {
+    /// Public site (same as room `public`)
+    Public {
+        #[command(subcommand)]
+        sub: ScopedCmd,
+    },
+    /// Private room id (`shortid/slug` from `room create`)
+    Private {
+        /// Room id, e.g. `a1b2c3d/my-project`
+        #[arg(value_name = "ROOM_ID")]
+        room: String,
+        #[command(subcommand)]
+        sub: ScopedCmd,
+    },
 
     /// Show all activity since you last posted (global feed)
     ///
@@ -628,6 +646,37 @@ fn http_client() -> Result<reqwest::Client> {
         .build()?)
 }
 
+async fn send_rpc(
+    client: &reqwest::Client,
+    base: &str,
+    bearer: Option<&str>,
+    commands: Vec<RpcCommand>,
+) -> Result<RpcBatchResponse> {
+    let url = format!("{}/api/v0/rpc", base.trim_end_matches('/'));
+    let mut req = client.post(url).json(&RpcBatch(commands));
+    if let Some(b) = bearer {
+        req = req.header("Authorization", format!("Bearer {}", b));
+    }
+    let resp = req.send().await?;
+    let status = resp.status();
+    let text = resp.text().await.unwrap_or_default();
+    if !status.is_success() {
+        return Err(anyhow!("rpc HTTP {}: {}", status, text.trim()));
+    }
+    serde_json::from_str(&text).map_err(|e| anyhow!("rpc response: {e}"))
+}
+
+fn rpc_line_ok(line: &RpcLine) -> Result<&RpcResult> {
+    if !line.ok {
+        let mut m = line.error.clone().unwrap_or_else(|| "rpc error".into());
+        if let Some(h) = &line.hint {
+            m.push_str(&format!("\nhint: {h}"));
+        }
+        return Err(anyhow!(m));
+    }
+    line.result.as_ref().ok_or_else(|| anyhow!("rpc missing result"))
+}
+
 /// Normalize ontology path for API. Accepts path with or without ~/ (shell expands ~ to $HOME).
 /// Returns a bare slug path (e.g. `languages/python`) with no leading `/` or `~/`.
 /// Call `ontology_path_for_api_query` before sending `item=` / `parent=` params so the server
@@ -729,231 +778,262 @@ fn write_secret_file(name: &str, contents: &str) -> Result<()> {
     Ok(())
 }
 
-#[tokio::main]
-async fn main() -> Result<()> {
-    let Cli { cmd, server } = Cli::parse();
-
-    // If no command provided, print the guide
-    let Some(cmd) = cmd else {
-        print!("{}", include_str!("../GUIDE.sorter"));
-        return Ok(());
-    };
-
-    let base = server.trim_end_matches('/');
-
-    match cmd {
-        Command::Healthz { json } => {
-            let client = http_client()?;
-            let url = format!("{base}/healthz");
-            let body = client.get(url).send().await?.text().await?;
-            if json {
-                // Wrap plain text response in a JSON object
-                println!("{}", serde_json::json!({ "ok": true, "body": body.trim() }));
-            } else {
-                println!("{body}");
-            }
-        }
-
-        Command::Search { query, json } => {
-            let client = http_client()?;
-            let url = format!("{base}/api/v0/search?q={}", urlencoding::encode(&query));
-            let resp: slug_types::SearchResponse = expect_json(client.get(url).send().await?).await?;
-            if json {
-                println!("{}", serde_json::to_string_pretty(&resp)?);
-            } else {
-                if !resp.items.is_empty() {
-                    println!("items ({})", resp.items.len());
-                    for item in &resp.items {
-                        print!("  {}", item.path);
-                        if let Some(body) = &item.body {
-                            let first_line = body.lines().next().unwrap_or("").trim();
-                            if !first_line.is_empty() {
-                                print!("  {}", first_line);
+async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
+    let room = room.trim();
+    let client = http_client()?;
+    match sub {
+        ScopedCmd::Garden { sub } => match sub {
+            GardenCmd::Tree { json } => {
+                let batch = send_rpc(&client, base, None, vec![RpcCommand::GetLeaves { room: room.to_string() }]).await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::Leaves(resp) => {
+                        if json {
+                            println!("{}", serde_json::to_string_pretty(&resp)?);
+                        } else {
+                            for p in &resp.paths {
+                                println!("~/{}", p);
                             }
                         }
-                        println!();
-                    }
-                }
-                if !resp.threads.is_empty() {
-                    if !resp.items.is_empty() { println!(); }
-                    println!("threads ({})", resp.threads.len());
-                    let now_ms = std::time::SystemTime::now()
-                        .duration_since(std::time::UNIX_EPOCH)
-                        .unwrap_or_default()
-                        .as_millis() as i64;
-                    for t in &resp.threads {
-                        println!("  {}  {}n  {}", t.tag, t.post_count, slug_types::timeago::timeago(now_ms, t.last_activity));
-                    }
-                }
-                if !resp.posts.is_empty() {
-                    if !resp.items.is_empty() || !resp.threads.is_empty() { println!(); }
-                    println!("posts ({})", resp.posts.len());
-                    let now_ms = std::time::SystemTime::now()
-                        .duration_since(std::time::UNIX_EPOCH)
-                        .unwrap_or_default()
-                        .as_millis() as i64;
-                    for p in &resp.posts {
-                        let first_line = p.snippet.lines().next().unwrap_or("").trim();
-                        println!("  {} · {}  {}", p.thread, slug_types::timeago::timeago(now_ms, p.ts), first_line);
-                    }
-                }
-                if resp.items.is_empty() && resp.threads.is_empty() && resp.posts.is_empty() {
-                    println!("no results");
-                }
-            }
-        }
-
-        Command::Garden { sub } => match sub {
-            GardenCmd::Tree { json } => {
-                let client = http_client()?;
-                let url = format!("{base}/api/v0/leaves");
-                let builder = client.get(url);
-                let resp: LeavesResponse = expect_json(builder.send().await?).await?;
-                if json {
-                    println!("{}", serde_json::to_string_pretty(&resp)?);
-                } else {
-                    for p in &resp.paths {
-                        println!("~/{}", p);
                     }
+                    _ => return Err(anyhow!("unexpected RPC result")),
                 }
             }
-
             GardenCmd::Body { path, json, full } => {
                 let path = normalize_ontology_path_input(&path).map_err(anyhow::Error::msg)?;
                 let item_q = ontology_path_for_api_query(&path);
-                let client = http_client()?;
-                let mut url = format!("{base}/api/v0/item?item={}", urlencoding::encode(&item_q));
-                if full {
-                    url.push_str("&full=true");
-                }
-                let builder = client.get(url);
-                let resp: ItemResponse = expect_json(builder.send().await?).await?;
-                if json {
-                    println!("{}", serde_json::to_string_pretty(&resp)?);
-                } else {
-                    print_item_response(&resp);
+                let batch = send_rpc(
+                    &client,
+                    base,
+                    None,
+                    vec![RpcCommand::GetGardenItem {
+                        room: room.to_string(),
+                        item_path: item_q,
+                        full: Some(full),
+                    }],
+                )
+                .await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::GardenItem(resp) => {
+                        if json {
+                            println!("{}", serde_json::to_string_pretty(&resp)?);
+                        } else {
+                            print_item_response(&resp);
+                        }
+                    }
+                    _ => return Err(anyhow!("unexpected RPC result")),
                 }
             }
-
             GardenCmd::Children { paths, depth, json } => {
                 let paths: Vec<String> = paths
                     .iter()
                     .map(|p| normalize_ontology_path_input(p).map_err(anyhow::Error::msg))
                     .collect::<Result<Vec<_>>>()?;
-                let client = http_client()?;
                 let parent_param = paths
                     .iter()
                     .map(|p| ontology_path_for_api_query(p))
                     .collect::<Vec<_>>()
                     .join(",");
-                let mut url = format!("{base}/api/v0/rank?parent={}", urlencoding::encode(&parent_param));
-                if let Some(d) = depth {
-                    url.push_str(&format!("&depth={d}"));
-                }
-                let builder = client.get(url);
-                let resp: RankResponse = expect_json(builder.send().await?).await?;
-
-                if json {
-                    println!("{}", serde_json::to_string_pretty(&resp)?);
-                } else {
-                    print_rank_response(&resp);
+                let batch = send_rpc(
+                    &client,
+                    base,
+                    None,
+                    vec![RpcCommand::GetGardenRank {
+                        room: room.to_string(),
+                        parent_path: parent_param,
+                        depth,
+                        offset: None,
+                        limit: None,
+                        percent: None,
+                    }],
+                )
+                .await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::GardenRank(resp) => {
+                        if json {
+                            println!("{}", serde_json::to_string_pretty(&resp)?);
+                        } else {
+                            print_rank_response(&resp);
+                        }
+                    }
+                    _ => return Err(anyhow!("unexpected RPC result")),
                 }
             }
-
             GardenCmd::Pair { path, json } => {
                 let path = normalize_ontology_path_input(&path).map_err(anyhow::Error::msg)?;
                 let parent_q = ontology_path_for_api_query(&path);
-                let client = http_client()?;
-                let url = format!("{base}/api/v0/pair?parent={}", urlencoding::encode(&parent_q));
-                let builder = client.get(url);
-                let resp: PairResponse = expect_json(builder.send().await?).await?;
-                if json {
-                    println!("{}", serde_json::to_string_pretty(&resp)?);
-                } else {
-                    print_pair_response(&resp);
+                let batch = send_rpc(
+                    &client,
+                    base,
+                    None,
+                    vec![RpcCommand::GetPair {
+                        room: room.to_string(),
+                        parent_path: parent_q,
+                    }]

… preview truncated; 237,234 characters omitted

download full diff A

B — c_552f408ae0da (tommy-mor)

message

[9acdf18a] feat: add RoomList RPC command and CLI room list subcommand

Returns all rooms the authenticated principal has a grant in.
Includes integration tests proving per-user isolation: users only
see rooms they have been explicitly granted, not all rooms in the system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff preview

diff --git a/bb.edn b/bb.edn
index f7c53eb2d5def514a8f2c480ac420416205db14e..8dc6a5be7321de198cbb5939842a33b8c5d52625 100644
--- a/bb.edn
+++ b/bb.edn
@@ -47,16 +47,18 @@
                                            "RUST_LOG"      "info"})})))}
 
   test
-  {:doc "Full test suite: integration + auth + grants + invites"
+  {:doc "Full test suite: integration + auth + grants + invites + room-list"
    :requires ([test.integration :as integration]
               [test.auth :as auth]
               [test.grants :as grants]
-              [test.invites :as invites])
+              [test.invites :as invites]
+              [test.room-list :as room-list])
    :task (do
            (integration/integration)
            (auth/auth-test)
            (grants/grants-test)
-           (invites/invites-test))}
+           (invites/invites-test)
+           (room-list/room-list-test))}
 
   walkthrough-fixture
   {:doc "Run local server + mock OAuth + seeded walkthrough data for manual browser demos"
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 69492cb5417a0a19c38f8cacbeadd103bd905b6f..b008cf377bb360aaae666d2dfd4b5e13dedb204a 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -219,6 +219,12 @@ enum RoomCmd {
         #[arg(long)]
         json: bool,
     },
+    /// List rooms the authenticated user has access to
+    List {
+        /// Output as JSON for agent parsing
+        #[arg(long)]
+        json: bool,
+    },
 }
 
 #[derive(Subcommand, Debug)]
@@ -1319,6 +1325,38 @@ async fn main() -> Result<()> {
                     _ => return Err(anyhow!("unexpected RPC result")),
                 }
             }
+            RoomCmd::List { json } => {
+                let client = http_client()?;
+                let bearer = effective_bearer().ok_or_else(|| {
+                    anyhow!(
+                        "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+                         then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+                    )
+                })?;
+                let batch = send_rpc(
+                    &client,
+                    base,
+                    Some(&bearer),
+                    vec![RpcCommand::RoomList],
+                )
+                .await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::RoomList(resp) => {
+                        if json {
+                            println!("{}", serde_json::to_string_pretty(&resp)?);
+                        } else {
+                            if resp.rooms.is_empty() {
+                                println!("no rooms");
+                            } else {
+                                for room in &resp.rooms {
+                                    println!("{room}");
+                                }
+                            }
+                        }
+                    }
+                    _ => return Err(anyhow!("unexpected RPC result")),
+                }
+            }
         },
 
         Command::Healthz { json } => {
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index ed4800e7cbdeaf04e72192c191e71354e603c1fe..f1ee6d35b95a1a28490823e907b8f4dc5c091b94 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -1345,6 +1345,25 @@ pub async fn handle_rpc_batch(
                     }
                 }
             }
+            RpcCommand::RoomList => {
+                let principal = {
+                    let reduced = state.reduced.read().await;
+                    verify_bearer_principal(&headers, &*reduced)
+                };
+                match principal {
+                    Err((_, m)) => line_err(m, None),
+                    Ok(principal) => {
+                        let reduced = state.reduced.read().await;
+                        let rooms: Vec<String> = reduced
+                            .grants
+                            .iter()
+                            .filter(|(_, members)| members.contains_key(&principal))
+                            .map(|(room, _)| room.clone())
+                            .collect();
+                        line_ok(RpcResult::RoomList(RoomListResponse { rooms }))
+                    }
+                }
+            }
             RpcCommand::RoomRevoke {
                 room,
                 username,
diff --git a/test/room_list.clj b/test/room_list.clj
new file mode 100644
index 0000000000000000000000000000000000000000..a089a722ac8d5f822f47d5511a46f671d61d46cf
--- /dev/null
+++ b/test/room_list.clj
@@ -0,0 +1,158 @@
+(ns test.room-list
+  "Room list integration test: list rooms user has access to via POST /api/v0/rpc.
+
+  Covers:
+  - user with no rooms -> empty list
+  - user with one room -> list contains that room
+  - user with multiple rooms -> list contains all rooms"
+  (:require [babashka.fs :as fs]
+            [cheshire.core :as json]
+            [clojure.set :as set]
+            [test.common :as common]
+            [test.oauth :as oauth]))
+
+(def ^:private counts (atom {:pass 0 :fail 0}))
+
+(defn- assert! [pred msg]
+  (common/test-assert! counts pred msg))
+
+(defn- bearer [token] {"Authorization" (str "Bearer " token)})
+
+(defn- rpc-batch! [base-url token cmds]
+  (let [resp (oauth/http-post-json (str base-url "/api/v0/rpc") cmds :headers (bearer token))]
+    {:status (:status resp)
+     :parsed (json/parse-string (:body resp) false)}))
+
+(defn- rpc-line-ok? [parsed]
+  (true? (get-in parsed ["results" 0 "ok"])))
+
+(defn- register-user! [base-url session-agent username]
+  (oauth/complete-registration! base-url
+                                :agent session-agent
+                                :username username
+                                :assert! (fn [pred msg] (assert! pred msg))))
+
+(defn room-list-test [& _args]
+  (println "\n━━━ room list integration check ━━━\n")
+  (reset! counts {:pass 0 :fail 0})
+
+  (println "building server binary…")
+  (common/letlocals
+   (bind build (common/run-cargo-build-release! ["slugsocial-server"]))
+   (assert! (zero? (:exit build)) "cargo build succeeds")
+   (bind server-bin "target/release/slugsocial-server")
+
+   (bind tmp-dir (str (fs/create-temp-dir {:prefix "slug-room-list-"})))
+   (bind slug-port (common/pick-port))
+   (bind google-port (common/pick-port))
+   (bind base-url (str "http://127.0.0.1:" slug-port))
+   (bind google-url (str "http://127.0.0.1:" google-port))
+
+   (bind !server (atom nil))
+   (bind !google (atom nil))
+
+   (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port))
+   (try
+     (println (str "starting mock google on :" google-port))
+     (reset! !google (oauth/start-mock-google google-port
+                                              :google-users ["google-user-alice"
+                                                             "google-user-bob"
+                                                             "google-user-carol"]))
+
+     (println (str "starting server on :" slug-port))
+     (reset! !server (common/start-server server-bin server-env))
+     (assert! (common/wait-for-server base-url 10000) "server responds to /healthz")
+
+     (println "\nregistering alice, bob, carol…")
+     (let [alice-token (register-user! base-url
+                                       "00000000-0000-0000-0000-000000000001:test:local/dev"
+                                       "alice")
+           bob-token   (register-user! base-url
+                                       "00000000-0000-0000-0000-000000000002:test:local/dev"
+                                       "bob")
+           carol-token (register-user! base-url
+                                       "00000000-0000-0000-0000-000000000003:test:local/dev"
+                                       "carol")
+
+           ;; Alice creates two private rooms
+           _ (println "\nalice creates two rooms…")
+           room-id-1 (-> (rpc-batch! base-url alice-token [{"RoomCreate" {"slug" "alice-room-one"}}])
+                         (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+           _ (assert! (some? room-id-1) "alice room-one created")
+           room-id-2 (-> (rpc-batch! base-url alice-token [{"RoomCreate" {"slug" "alice-room-two"}}])
+                         (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+           _ (assert! (some? room-id-2) "alice room-two created")
+
+           ;; Carol creates her own room
+           _ (println "carol creates her own room…")
+           carol-room (-> (rpc-batch! base-url carol-token [{"RoomCreate" {"slug" "carol-room"}}])
+                          (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+           _ (assert! (some? carol-room) "carol room created")]
+
+       ;; --- isolation: alice only sees her rooms, not carol's ---
+       (println "\nalice sees her 2 rooms but not carol's…")
+       (let [rooms (-> (rpc-batch! base-url alice-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+                       set)]
+         (assert! (= #{room-id-1 room-id-2} rooms)
+                  "alice sees exactly her 2 rooms")
+         (assert! (not (contains? rooms carol-room))
+                  "alice does NOT see carol's room"))
+
+       ;; --- isolation: carol only sees her room, not alice's ---
+       (println "carol sees only her room…")
+       (let [rooms (-> (rpc-batch! base-url carol-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+                       set)]
+         (assert! (= #{carol-room} rooms)
+                  "carol sees exactly her own room")
+         (assert! (not (contains? rooms room-id-1))
+                  "carol does NOT see alice's room-one")
+         (assert! (not (contains? rooms room-id-2))
+                  "carol does NOT see alice's room-two"))
+
+       ;; --- bob sees nothing yet: alice has 3 rooms total but bob is in none ---
+       (println "bob (no grants) sees no rooms despite 3 existing…")
+       (let [rooms (-> (rpc-batch! base-url bob-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"]))]
+         (assert! (zero? (count rooms))
+                  "bob sees 0 rooms even though 3 exist in the system"))
+
+       ;; --- partial grant: alice grants bob room-one only ---
+       (println "\nalice grants bob view on room-one only…")
+       (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token
+                                                   [{"RoomGrant" {"room" room-id-1
+                                                                  "username" "bob"
+                                                                  "capabilities" ["view"]}}])))
+                "grant ok")
+
+       ;; bob sees room-one but NOT room-two or carol's room
+       (println "bob sees room-one but not room-two or carol's room…")
+       (let [rooms (-> (rpc-batch! base-url bob-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+                       set)]
+         (assert! (= #{room-id-1} rooms)
+                  "bob sees exactly room-one")
+         (assert! (not (contains? rooms room-id-2))
+                  "bob does NOT see alice's room-two (not granted)")
+         (assert! (not (contains? rooms carol-room))
+                  "bob does NOT see carol's room (not granted)"))
+
+       ;; alice's view is unchanged
+       (println "alice's view unchanged after granting bob…")
+       (let [rooms (-> (rpc-batch! base-url alice-token ["RoomList"])
+                       (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+                       set)]
+         (assert! (= #{room-id-1 room-id-2} rooms)
+                  "alice still sees exactly her 2 rooms after granting bob")))
+
+     (fin

… preview truncated; 2,078 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.