constitution · epochs · watch · epoch 3

comparison

c_552f408ae0da (tommy-mor) vs c_abd68b5e771a (tommy-mor)

download prompt · raw event · cmp_a5a7a718d0859e

council reasoning

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

B fixes a real correctness bug (ghost items and falsely-marked voted pairs from zero-ratio votes) with a minimal, precise change and updates the test to verify the corrected invariant. A adds a legitimate new feature with good test coverage, but B's change addresses actual data-integrity bugs in core reducer logic that could silently corrupt state, which has more lasting correctness value than an additive feature.

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

B fixes a real correctness bug in core reducer logic: the zero-ratio guard ran after ensure_item and voted_pairs.insert, leaving ghost items and false voted-pair marks; moving the check before side effects permanently stops that corruption, with the unit test updated to match. A adds a useful RoomList RPC/CLI surface plus solid isolation tests, but it is additive API wiring over existing grants state rather than repairing flawed core behavior.

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

Side A adds a new end-to-end capability: a RoomList RPC, CLI `room list` subcommand, shared request/response types, server implementation, and integration tests verifying authenticated per-user room isolation. Side B fixes a real reducer bug by moving the zero-ratio early return before `ensure_item` and `voted_pairs` mutation, preventing ghost items and stale voted-pair state, but it is a localized correctness fix compared with A's broadly useful, fully tested feature.

sides

A — 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 A

B — c_abd68b5e771a (tommy-mor)

message

[81de487b] Fix zero-ratio guard in reducer to drop before registering items or pair.

Previously the early-return for zero-weight votes happened after
ensure_item and voted_pairs.insert, leaving ghost items in the index
and the pair incorrectly marked as voted. Move the check to before
any side effects.

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

diff preview

diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index 6841d35cfc9de2389f340a22b8a45acb335e36c3..0e36979abe0f051493038ff7e652efc7f7a0ac80 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -112,6 +112,10 @@ impl GroupState {
         if vote.ratio_right < 0 {
             vote.ratio_right = 0;
         }
+        if vote.ratio_left == 0 || vote.ratio_right == 0 {
+            // Zero on either side produces no valid edge; drop before registering items or pair.
+            return;
+        }
 
         let a_idx = self.ensure_item(&vote.a);
         let b_idx = self.ensure_item(&vote.b);
@@ -121,10 +125,6 @@ impl GroupState {
 
         let w_a = vote.ratio_left as f64;
         let w_b = vote.ratio_right as f64;
-        if w_a == 0.0 || w_b == 0.0 {
-            // Zero on either side produces no valid edge; drop the vote.
-            return;
-        }
 
         self.add_edge_weight(b_idx, a_idx, w_a);
         self.add_edge_weight(a_idx, b_idx, w_b);
diff --git a/server/tests/basic.rs b/server/tests/basic.rs
index cc8c1a0d139f3722ba6ecd13dd001c65be835b67..08159f4a7f0850fd165817a4a1af4f31ced2ad76 100644
--- a/server/tests/basic.rs
+++ b/server/tests/basic.rs
@@ -546,12 +546,10 @@ fn reducer_negative_ratio_clamped_to_zero() {
         delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
         thread_tag: "t".to_string(),
     });
-    // Items are registered, but the zero-clamped vote produces no edges.
-    assert_eq!(group.idx_to_item.len(), 2);
-    let a_idx = group.item_to_idx[&item_id("https://slug.social/~/t/a")];
-    let b_idx = group.item_to_idx[&item_id("https://slug.social/~/t/b")];
-    assert!(!group.edges.contains_key(&(a_idx, b_idx)));
-    assert!(!group.edges.contains_key(&(b_idx, a_idx)));
+    // Nothing registered: zero-clamped vote is dropped before ensure_item.
+    assert!(group.idx_to_item.is_empty());
+    assert!(group.edges.is_empty());
+    assert!(group.voted_pairs.is_empty());
 }
 
 

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.