B adds a genuine new capability (RoomList RPC + CLI subcommand) with real access-control semantics (per-user grant filtering) plus a thorough integration test proving isolation, giving durable end-user and security value. A is a solid internal tooling improvement (faster scan, better error surfacing, ingest replay) but is more niche dev-tooling churn without new user-facing functionality or security-relevant guarantees.
constitution · epochs · watch · epoch 3
c_6209cd238b3f (tommy-mor) vs c_552f408ae0da (tommy-mor)
download prompt · raw event · cmp_4989580d22d3a9
council reasoning
B adds a core product RPC (RoomList) plus CLI and integration tests that lock in per-principal grant isolation—lasting API surface the app needs. A is strong offline DX (fast parse-only scan, richer parse_error, compile --ingest) but remains tooling around logs rather than new user-facing capability; B’s value is thinner code-wise but more central and durable for the system.
Side A delivers deeper tooling improvements by redesigning JSONL scanning into a fast parse-only pass, surfacing detailed DSL parse errors, and adding `compile --ingest` that reconstructs prior reducer state for single-event replay, with corresponding API changes and tests. Side B adds a useful `RoomList` RPC, CLI subcommand, response types, and integration tests for access isolation, but it is a relatively self-contained feature compared with Side A's broader performance, diagnostics, and developer workflow improvements.
sides
A — c_6209cd238b3f (tommy-mor)
message
[6e344666] Improve sorterc scan speed, errors, and ingest compile. Make scan a fast DSL parse pass with human-readable output, surface full parse_error details, and add compile --ingest for single-event replay from a log. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/offline.rs b/server/src/offline.rs
index 54ad0ded096a305ef8454ab2cdd1c3af71b14f5d..2db6f67e22e00a24ce673d13095644dd9fa9342d 100644
--- a/server/src/offline.rs
+++ b/server/src/offline.rs
@@ -28,6 +28,10 @@ pub struct CompileResult {
pub threads: Vec<String>,
pub rankings: Vec<CheckScopeRanking>,
pub stats: CompileStats,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub ingest_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub ingest_line: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
@@ -36,6 +40,8 @@ pub struct CompileError {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub parse_error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
@@ -50,7 +56,7 @@ pub struct MalformedIngest {
pub id: String,
pub room_id: String,
pub thread_tag: String,
- pub reason: String,
+ pub parse_error: String,
}
#[derive(Debug, Clone, Serialize)]
@@ -59,9 +65,36 @@ pub struct ScanResult {
pub path: String,
pub total_lines: usize,
pub parsed_events: usize,
+ pub ingest_events: usize,
pub bad_json_lines: Vec<BadJsonLine>,
pub malformed_ingests: Vec<MalformedIngest>,
- pub skipped_ingests: usize,
+}
+
+#[derive(Debug)]
+pub enum CompileIngestError {
+ NotFound(String),
+ Io(std::io::Error),
+ Compile(CompileError),
+}
+
+impl CompileIngestError {
+ pub fn into_compile_error(self) -> CompileError {
+ match self {
+ Self::NotFound(id) => CompileError {
+ ok: false,
+ error: format!("ingest not found: {id}"),
+ hint: Some("pass the ingest event id from events.jsonl".into()),
+ parse_error: None,
+ },
+ Self::Io(e) => CompileError {
+ ok: false,
+ error: format!("io error: {e}"),
+ hint: None,
+ parse_error: None,
+ },
+ Self::Compile(e) => e,
+ }
+ }
}
fn document_stats(doc: &dsl::Document) -> CompileStats {
@@ -166,19 +199,22 @@ fn rankings_for_simulated(
.collect()
}
-/// Validate and simulate one `.sorter` document against optional base reducer state.
-pub fn compile_document(
+fn compile_document_inner(
base: &ReducerState,
room: &str,
text: &str,
+ ingest_id: Option<String>,
+ ingest_line: Option<usize>,
) -> Result<CompileResult, CompileError> {
let room_key = room.trim();
let scope = scope_from_room_wire(room_key);
let validated = validate_ingest_document(base, text, &scope).map_err(|(_, message, hint)| {
+ let parse_error = dsl::parse_full(text).err().map(|e| e.to_string());
CompileError {
ok: false,
error: message,
hint,
+ parse_error,
}
})?;
@@ -200,15 +236,27 @@ pub fn compile_document(
threads: threads_in_document(text),
rankings: rankings_for_simulated(&simulated, &scope, room_key, &validated.doc),
stats: document_stats(&validated.doc),
+ ingest_id,
+ ingest_line,
})
}
+/// Validate and simulate one `.sorter` document against optional base reducer state.
+pub fn compile_document(
+ base: &ReducerState,
+ room: &str,
+ text: &str,
+) -> Result<CompileResult, CompileError> {
+ compile_document_inner(base, room, text, None, None)
+}
+
fn ingest_parse_error(raw: &str) -> Option<String> {
dsl::parse_full(raw).err().map(|e| e.to_string())
}
-fn load_events_from_jsonl(path: &Path) -> Result<(Vec<(usize, Event)>, Vec<BadJsonLine>), std::io::Error> {
+fn load_events_from_jsonl(path: &Path) -> Result<(usize, Vec<(usize, Event)>, Vec<BadJsonLine>), std::io::Error> {
let text = std::fs::read_to_string(path)?;
+ let total_lines = text.lines().count();
let mut events = Vec::new();
let mut bad_json_lines = Vec::new();
for (idx, line) in text.lines().enumerate() {
@@ -225,61 +273,90 @@ fn load_events_from_jsonl(path: &Path) -> Result<(Vec<(usize, Event)>, Vec<BadJs
}),
}
}
- Ok((events, bad_json_lines))
+ Ok((total_lines, events, bad_json_lines))
}
-/// Replay a JSONL event log into reducer state (same rules as server boot).
-pub fn load_reducer_from_jsonl(path: &Path) -> Result<(ReducerState, Vec<BadJsonLine>), std::io::Error> {
- let (events, bad_json_lines) = load_events_from_jsonl(path)?;
+fn replay_events(events: &[(usize, Event)]) -> ReducerState {
let mut state = ReducerState::default();
for (_line_no, ev) in events {
- state.apply_event(ev);
+ state.apply_event(ev.clone());
}
- Ok((state, bad_json_lines))
+ state
}
-/// Scan an events.jsonl for corrupt JSON lines and ingests that fail DSL replay.
-pub fn scan_jsonl(path: &Path) -> Result<ScanResult, std::io::Error> {
- let text = std::fs::read_to_string(path)?;
- let total_lines = text.lines().count();
- let (events, bad_json_lines) = load_events_from_jsonl(path)?;
+/// Replay a JSONL event log into reducer state (same rules as server boot).
+pub fn load_reducer_from_jsonl(path: &Path) -> Result<(ReducerState, Vec<BadJsonLine>), std::io::Error> {
+ let (_total_lines, events, bad_json_lines) = load_events_from_jsonl(path)?;
+ Ok((replay_events(&events), bad_json_lines))
+}
- let mut malformed_ingests = Vec::new();
- let mut skipped_ingests = 0usize;
- let mut state = ReducerState::default();
- let parsed_events = events.len();
+/// Find one ingest in a log and compile it against all prior events as base state.
+pub fn compile_ingest_from_log(path: &Path, ingest_id: &str) -> Result<CompileResult, CompileIngestError> {
+ let (_total_lines, events, bad_json_lines) = load_events_from_jsonl(path).map_err(CompileIngestError::Io)?;
+ if !bad_json_lines.is_empty() {
+ return Err(CompileIngestError::Compile(CompileError {
+ ok: false,
+ error: format!("jsonl has {} corrupt line(s)", bad_json_lines.len()),
+ hint: Some("fix the log or use `sorterc scan`".into()),
+ parse_error: None,
+ }));
+ }
+
+ let needle = ingest_id.trim();
+ let mut found: Option<(usize, Ingest)> = None;
+ let mut prior: Vec<(usize, Event)> = Vec::new();
for (line_no, ev) in events {
if let Event::Ingest(ref ing) = ev {
- if let Some(reason) = ingest_parse_error(&ing.raw) {
+ if ing.id == needle {
+ found = Some((line_no, ing.clone()));
+ break;
+ }
+ }
+ prior.push((line_no, ev));
+ }
+
+ let (line_no, ing) = found.ok_or_else(|| CompileIngestError::NotFound(needle.to_string()))?;
+ let base = replay_events(&prior);
+ compile_document_inner(&base, &ing.room_id, &ing.raw, Some(ing.id.clone()), Some(line_no))
+ .map_err(CompileIngestError::Compile)
+}
+
+/// Scan an events.jsonl for corrupt JSON lines and ingests whose DSL fails to parse.
+///
+/// This does not replay the log (which would run rank centrality on every ingest and
+/// can take minutes on real logs). It matches what the server skips on boot: parse failure.
+pub fn scan_jsonl(path: &Path) -> Result<ScanResult, std::io::Error> {
+ let (total_lines, events, bad_json_lines) = load_events_from_jsonl(path)?;
+
+ let mut malformed_ingests = Vec::new();
+ let mut ingest_events = 0usize;
+
+ for (line_no, ev) in &events {
+ if let Event::Ingest(ing) = ev {
+ ingest_events += 1;
+ if let Some(parse_error) = ingest_parse_error(&ing.raw) {
malformed_ingests.push(MalformedIngest {
- line: line_no,
+ line: *line_no,
id: ing.id.clone(),
room_id: ing.room_id.clone(),
thread_tag: ing.thread_tag.clone(),
- reason,
+ parse_error,
});
}
- let before = state.ingests_by_id.len();
- state.apply_event(ev);
- if state.ingests_by_id.len() == before {
- skipped_ingests += 1;
- }
- } else {
- state.apply_event(ev);
}
}
- let ok = bad_json_lines.is_empty() && malformed_ingests.is_empty() && skipped_ingests == 0;
+ let ok = bad_json_lines.is_empty() && malformed_ingests.is_empty();
Ok(ScanResult {
ok,
path: path.display().to_string(),
total_lines,
- parsed_events,
+ parsed_events: events.len(),
+ ingest_events,
bad_json_lines,
malformed_ingests,
- skipped_ingests,
})
}
@@ -330,4 +407,25 @@ mod tests {
assert!(!report.ok);
assert_eq!(report.bad_json_lines.len(), 1);
}
+
+ #[test]
+ fn scan_reports_dsl_parse_error_detail() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("events.jsonl");
+ let ingest = serde_json::json!({
+ "type": "ingest",
+ "ts": 1,
+ "id": "bad-ingest-id",
+ "raw": "{ no closing brace\n~/a { body }\n~/a 1:0 ~/b",
+ "principal": "test",
+ "room_id": "public",
+ "thread_tag": "t",
+ });
+ std::fs::write(&path, format!("{ingest}\n")).unwrap();
+ let report = scan_jsonl(&path).unwrap();
+ assert!(!report.ok);
+ assert_eq!(report.malformed_ingests.len(), 1);
+ assert_eq!(report.malformed_ingests[0].id, "bad-ingest-id");
+ assert!(report.malformed_ingests[0].parse_error.contains("parse error"));
+ }
}
diff --git a/sorterc/readme.md b/sorterc/readme.md
index 1ebcc3fc935541ea9e47e0458ec67a750fe19fff..e388615c34b40914ca51fc7a79cb738eebc2909b 100644
--- a/sorterc/readme.md
+++ b/sorterc/readme.md
@@ -60,21 +60,33 @@ Rankings use the same structure as the server's dry-run check: parent scope, con
### `scan` — lint an `events.jsonl`
-Reads a JSONL event log and reports problems without starting a server.
+Fast single-pass check. Does **not** replay the log (replay runs rank centrality on every ingest and gets slow fast).
```bash
cargo run -p sorterc -- scan events.jsonl
-cargo run -p sorterc -- scan events.jsonl --pretty
+cargo run -p sorterc -- scan events.jsonl --json
+cargo run -p sorterc -- scan events.jsonl --json --pretty
```
+Default output is human-readable with a blank line between each problem. Use `--json` for machine output.
+
Reports:
- **bad JSON lines** — lines that are not valid JSON
-- **malformed ingests** — ingest events whose `raw` DSL fails to parse
-- **skipped ingests** — ingests dropped during replay (same behavior as server boot)
+- **malformed ingests** — ingest events whose `raw` DSL fails to parse, with full `parse_error` text
Exits 0 when clean, 1 when any issue is found.
+### `compile --ingest` — compile one event from a log
+
+Replay all events **before** the target ingest as base state, then compile that ingest's DSL:
+
+```bash
+cargo run -p sorterc -- compile --ingest cabd8adc-57ae-402d-a940-8e24339ac451 --from events.jsonl --pretty
+```
+
+Output includes `ingest_id`, `ingest_line`, and rankings for that post only. This may take a while for ingests late in a large log (full replay up to that point).
+
## Typical uses
- Iterate on `.sorter` files in an editor and pipe through `compile` to see rankings instantly
diff --git a/sorterc/src/main.rs b/sorterc/src/main.rs
index 71382c180085cb0ad71043c852f8db5d3a48a284..5687c850d16973d28749380b8dc89f3bcebbfc56 100644
--- a/sorterc/src/main.rs
+++ b/sorterc/src/main.rs
@@ -22,9 +22,15 @@ struct Cli {
enum Command {
/// Parse and simulate a .sorter document; emit ranking JSON to stdout.
Compile {
- /// `.sorter` file, or `-` f
… preview truncated; 6,030 characters omittedB — 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 omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.