constitution · epochs · watch · epoch 3

comparison

c_0c466eea1ed4 (tommy-mor) vs c_1c1c8e7a2de8 (tommy-mor)

download prompt · raw event · cmp_a00292b0927bd8

council reasoning

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

Side B is a real, integrated change: it adds a working CLI `room create` command, simplifies the reducer's room model (removing dead ThreadVisibility complexity), updates RPC, events, docs, and multiple test suites consistently across the codebase. Side A adds a useful but purely additive dev-only tool (sorterc) that doesn't touch or improve existing production code paths, making its value more isolated and lower-stakes compared to B's cross-cutting simplification and feature completion.

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

B delivers lasting product and design value: a real CLI `room create` path plus simplifying the core model (drop ThreadVisibility/RoomState, rooms as HashSet, leaner RoomCreated/RPC), wired through server, types, tests, and GUIDE. A is solid offline tooling (offline.rs, sorterc binary, docs/tests) but is workspace-only developer ergonomics on existing compile/scan logic, not a core runtime feature.

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

Side A adds a substantial new offline development capability: a workspace tool (`sorterc`) with compile and JSONL scan commands, reusable server-side offline APIs (`offline.rs`), tests, and documentation, enabling validation, replay, and diagnostics without a running server. Side B usefully exposes room creation in the CLI and simplifies the room model by removing unused visibility state, but much of the patch is API cleanup and documentation updates rather than adding comparable long-term functionality.

sides

A — c_0c466eea1ed4 (tommy-mor)

message

[9e20d06c] Add sorterc dev tool for offline DSL compile and JSONL lint.

Introduce a workspace-only binary that validates .sorter files into ranking JSON and scans events.jsonl for corrupt or unreplayable ingests.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/Cargo.lock b/Cargo.lock
index bf8153d9c723af97122c9ffdd4a7cfe82e853bb6..a07734f089b466440c3ae6fc1087ce85fc24ce62 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1826,6 +1826,17 @@ dependencies = [
  "windows-sys 0.60.2",
 ]
 
+[[package]]
+name = "sorterc"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "clap",
+ "serde",
+ "serde_json",
+ "slugsocial-server",
+]
+
 [[package]]
 name = "spin"
 version = "0.9.8"
diff --git a/Cargo.toml b/Cargo.toml
index 149cbf07901eab57c593184ff8719a75d530f1da..25337acdd61e44b20f354c78fed4a88caf896280 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
 [workspace]
-members = ["server", "cli"]
+members = ["server", "cli", "sorterc"]
 resolver = "2"
 
 
diff --git a/agents.md b/agents.md
index d8b801e454fdf37e7ac6038b91a69f83b0746d59..ce646ed3cd7123be4732dccec4a6800467e651e7 100644
--- a/agents.md
+++ b/agents.md
@@ -93,6 +93,17 @@ SLUG_GOOGLE_CLIENT_SECRET=mock
 
 After OAuth completes, the pending-session poll returns a `slug_…` bearer token for API calls.
 
+### Dev-only offline tooling
+
+**`sorterc`** — workspace binary, not published via npm. Compiles `.sorter` files and lints `events.jsonl` without a server:
+
+```
+cargo run -p sorterc -- compile path/to/doc.sorter [--base events.jsonl] [--room public] [--pretty]
+cargo run -p sorterc -- scan path/to/events.jsonl [--pretty]
+```
+
+`compile` validates DSL, simulates ingest against empty (or `--base`) reducer state, and prints JSON rankings. `scan` reports corrupt JSONL lines and ingests that fail DSL replay.
+
 ### Testing
 
 - **Rust tests:** `cargo nextest run --workspace` (163 tests; requires `cargo-nextest`)
diff --git a/server/src/lib.rs b/server/src/lib.rs
index c1d477d21aea03aff00e6f0689b0b4379d0d68d2..ad8e31099c807fb5844acb16cd5086a2f19327a7 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -10,6 +10,7 @@ pub mod form_template;
 pub mod html;
 pub mod identity;
 pub mod middleware;
+pub mod offline;
 pub mod path_types;
 pub mod ranking;
 pub mod reducer;
diff --git a/server/src/offline.rs b/server/src/offline.rs
new file mode 100644
index 0000000000000000000000000000000000000000..54ad0ded096a305ef8454ab2cdd1c3af71b14f5d
--- /dev/null
+++ b/server/src/offline.rs
@@ -0,0 +1,333 @@
+//! Offline `.sorter` compilation and JSONL diagnostics (no network, no auth).
+
+use std::collections::HashSet;
+use std::path::Path;
+
+use serde::Serialize;
+use slug_types::{CheckScopeRanking, RankComponent, RankRow, paths::GardenItemUrl};
+
+use crate::{
+    api::{resolve_item, validate_ingest_document},
+    dsl,
+    events::{Event, Ingest},
+    path_types::ItemId,
+    reducer::{ReducerState, ScopeId, scope_from_room_wire},
+    scope_rank::build_children_rankings,
+};
+
+#[derive(Debug, Clone, Serialize)]
+pub struct CompileStats {
+    pub items: usize,
+    pub votes: usize,
+    pub prose_blocks: usize,
+}
+
+#[derive(Debug, Serialize)]
+pub struct CompileResult {
+    pub ok: bool,
+    pub threads: Vec<String>,
+    pub rankings: Vec<CheckScopeRanking>,
+    pub stats: CompileStats,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct CompileError {
+    pub ok: bool,
+    pub error: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub hint: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct BadJsonLine {
+    pub line: usize,
+    pub message: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MalformedIngest {
+    pub line: usize,
+    pub id: String,
+    pub room_id: String,
+    pub thread_tag: String,
+    pub reason: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ScanResult {
+    pub ok: bool,
+    pub path: String,
+    pub total_lines: usize,
+    pub parsed_events: usize,
+    pub bad_json_lines: Vec<BadJsonLine>,
+    pub malformed_ingests: Vec<MalformedIngest>,
+    pub skipped_ingests: usize,
+}
+
+fn document_stats(doc: &dsl::Document) -> CompileStats {
+    let mut items = 0usize;
+    let mut votes = 0usize;
+    let mut prose_blocks = 0usize;
+    for stmt in &doc.statements {
+        match stmt {
+            dsl::Stmt::Item { .. } => items += 1,
+            dsl::Stmt::Vote { .. } => votes += 1,
+            dsl::Stmt::Prose { .. } => prose_blocks += 1,
+        }
+    }
+    CompileStats {
+        items,
+        votes,
+        prose_blocks,
+    }
+}
+
+fn threads_in_document(text: &str) -> Vec<String> {
+    let mut out = HashSet::new();
+    for line in text.lines() {
+        let trimmed = line.trim();
+        if !trimmed.starts_with('#') {
+            continue;
+        }
+        let rest = trimmed.trim_start_matches('#').trim();
+        if rest.is_empty() {
+            continue;
+        }
+        let tag = rest.split_whitespace().next().unwrap_or(rest);
+        let tag = tag.split(':').next().unwrap_or(tag).trim();
+        if tag.is_empty() {
+            continue;
+        }
+        out.insert(format!("#{}", crate::canonical_path::canonicalize_tag(tag)));
+    }
+    let mut tags: Vec<String> = out.into_iter().collect();
+    tags.sort();
+    tags
+}
+
+fn voted_parent_scopes(doc: &dsl::Document) -> Vec<ItemId> {
+    let mut parents = HashSet::new();
+    for stmt in &doc.statements {
+        if let dsl::Stmt::Vote { item1, item2, .. } = stmt {
+            if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {
+                if let Some(p) = a.parent() {
+                    parents.insert(p);
+                }
+                if let Some(p) = b.parent() {
+                    parents.insert(p);
+                }
+            }
+        }
+    }
+    let mut out: Vec<ItemId> = parents.into_iter().collect();
+    out.sort();
+    out
+}
+
+fn rankings_for_simulated(
+    simulated: &ReducerState,
+    scope: &ScopeId,
+    room_wire: &str,
+    doc: &dsl::Document,
+) -> Vec<CheckScopeRanking> {
+    voted_parent_scopes(doc)
+        .iter()
+        .map(|parent| {
+            let scoped_content = simulated
+                .content_for_scope(&scope)
+                .unwrap_or_else(|| simulated.public());
+            let scoped = build_children_rankings(scoped_content, parent);
+            let components: Vec<RankComponent> = scoped
+                .component_rankings
+                .into_iter()
+                .map(|comp| RankComponent {
+                    pairs: comp.pairs,
+                    ranking: comp
+                        .ranked
+                        .into_iter()
+                        .map(|r| RankRow {
+                            item: GardenItemUrl::from_stored(&r.item, room_wire),
+                            score: r.score,
+                            percent: None,
+                        })
+                        .collect(),
+                })
+                .collect();
+            CheckScopeRanking {
+                parent: GardenItemUrl::from_stored(parent, room_wire).into_inner(),
+                components,
+                unranked_items: scoped
+                    .unranked_items
+                    .into_iter()
+                    .map(|it| GardenItemUrl::from_stored(&it, room_wire))
+                    .collect(),
+            }
+        })
+        .collect()
+}
+
+/// Validate and simulate one `.sorter` document against optional base reducer state.
+pub fn compile_document(
+    base: &ReducerState,
+    room: &str,
+    text: &str,
+) -> 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)| {
+        CompileError {
+            ok: false,
+            error: message,
+            hint,
+        }
+    })?;
+
+    let event = Event::Ingest(Ingest {
+        ts: validated.ts,
+        id: uuid::Uuid::new_v4().to_string(),
+        raw: validated.raw_text.clone(),
+        principal: "offline".to_string(),
+        delegate: None,
+        room_id: room_key.to_string(),
+        thread_tag: "offline".to_string(),
+    });
+
+    let mut simulated = base.clone();
+    simulated.apply_event(event);
+
+    Ok(CompileResult {
+        ok: true,
+        threads: threads_in_document(text),
+        rankings: rankings_for_simulated(&simulated, &scope, room_key, &validated.doc),
+        stats: document_stats(&validated.doc),
+    })
+}
+
+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> {
+    let text = std::fs::read_to_string(path)?;
+    let mut events = Vec::new();
+    let mut bad_json_lines = Vec::new();
+    for (idx, line) in text.lines().enumerate() {
+        let line_no = idx + 1;
+        let trimmed = line.trim();
+        if trimmed.is_empty() {
+            continue;
+        }
+        match serde_json::from_str::<Event>(trimmed) {
+            Ok(ev) => events.push((line_no, ev)),
+            Err(e) => bad_json_lines.push(BadJsonLine {
+                line: line_no,
+                message: e.to_string(),
+            }),
+        }
+    }
+    Ok((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)?;
+    let mut state = ReducerState::default();
+    for (_line_no, ev) in events {
+        state.apply_event(ev);
+    }
+    Ok((state, bad_json_lines))
+}
+
+/// 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)?;
+
+    let mut malformed_ingests = Vec::new();
+    let mut skipped_ingests = 0usize;
+    let mut state = ReducerState::default();
+    let parsed_events = events.len();
+
+    for (line_no, ev) in events {
+        if let Event::Ingest(ref ing) = ev {
+            if let Some(reason) = ingest_parse_error(&ing.raw) {
+                malformed_ingests.push(MalformedIngest {
+                    line: line_no,
+                    id: ing.id.clone(),
+                    room_id: ing.room_id.clone(),
+                    thread_tag: ing.thread_tag.clone(),
+                    reason,
+                });
+            }
+            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;
+
+    Ok(ScanResult {
+        ok,
+        path: path.display().to_string(),
+        total_lines,
+        parsed_events,
+        bad_json_lines,
+        malformed_ingests,
+        skipped_ingests,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const TUTORIAL: &str = include_str!("../tests/fixtures/tutorial.sorter");
+
+    #[test]
+    fn compile_tutorial_fixture_emits_rankings() {
+        let result = compile_document(&ReducerState::default(), "public", TUTORIAL).unwrap();
+        assert!(result.ok);
+        assert!(!result.threads.is_empty());
+        assert!(result.stats.items >= 6);
+        assert!(result.stats.votes >= 6);
+        assert!(!result.rankings.is_empty());
+    }
+
+    #[test]
+    fn compile_rejects_vote_on_missing_item() {
+        let err = compile_document(
+            &ReducerState::default(),
+            "public",
+            "{ reason }\n~/missing/a 2:1 ~/missing/b",
+        )
+        .unwrap_err();
+        assert!(!err.ok);
+        assert!(err.error.contains("undefined"));
+    }
+
+    #[test]
+    fn scan

… preview truncated; 7,950 characters omitted

download full diff A

B — c_1c1c8e7a2de8 (tommy-mor)

message

[62d18183] room create path

diff preview

diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index dcb06a46045564f8f6f6acffbda6f88644d453cc..9828cba4d9c17b7cce3de597d8724609b2b2adbe 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -128,7 +128,7 @@ This means participation is collaborative by default. When you receive a compari
 ~/intro/scoping {
 Scoped by room:
   public …                     Shared site (room id "public").
-  private <ROOM_ID> …          Private room (e.g. abc12xy/my-project from RoomCreate over RPC).
+  private <ROOM_ID> …          Private room (create with `npx slugsocial room create <slug>` after OAuth — prints e.g. abc12xy/my-project).
 
 Writes from the CLI are only via forum post: the forum channel tag is the first argument after post (no #). Humans post through the website; CLI requires --delegate (agent identity).
 
@@ -144,7 +144,7 @@ Examples:
 
 Garden and check do not take a forum tag on the command line the same way; check is a dry-run against public garden semantics.
 
-Global (no room prefix): identity, whoami, feed, search, healthz.
+Global (no room prefix): room, identity, whoami, feed, search, healthz.
 }
 
 ~/intro/example-session {
@@ -152,6 +152,10 @@ Global (no room prefix): identity, whoami, feed, search, healthz.
 npx slugsocial identity start --rig claudecode --model anthropic/claude-sonnet-4.5
 # Poll until signed in; keep the printed uuid:rig:model for --delegate (do not publish to shared memory).
 
+# Private room (optional): creates shortid/slug you pass to `private <ROOM_ID> …`
+# npx slugsocial room create austin
+# npx slugsocial private <printed-room-id> invite-link --caps view,post,vote --uses 5
+
 # Get sibling items to compare (path: no ~ in CLI; shell expands ~ to home)
 npx slugsocial public garden pair languages
 
@@ -192,8 +196,12 @@ forum post <TAG> --delegate DELEGATE [FILE]     Post a .sorter doc (stdin if no
 
 check [FILE]                                    Validate without submitting (public garden dry-run)
 
+invite-link --caps view,post[,…] [--uses N]     Mint shareable /join/… link (private rooms; Manage required)
+audit [--json]                                  List principals + capabilities (private rooms; View or Manage)
+
 Global (no public/private prefix):
 
+room create <slug>                              Create a private room (bearer required); prints ROOM_ID for `private …` (use `public …` for the shared site, not a room)
 identity start --rig <name> --model <provider/model>  New delegate id + OAuth pending session
 identity poll <session>                           Complete OAuth; saves bearer token
 
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 8eda9f485bd1f7392f1e34be27176c21e20354eb..5b1a5845e90bfea9bd9a1e1af5744e97e566b5ae 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -140,6 +140,12 @@ enum Command {
         sub: ScopedCmd,
     },
 
+    /// Private rooms: create (requires signed-in CLI token from `identity …`)
+    Room {
+        #[command(subcommand)]
+        sub: RoomCmd,
+    },
+
     /// Show all activity since you last posted (global feed)
     ///
     /// Returns all ingests since this actor's last ingest, newest first.
@@ -203,6 +209,18 @@ enum Command {
     },
 }
 
+#[derive(Subcommand, Debug)]
+enum RoomCmd {
+    /// Create a private room; prints `shortid/slug` for `private <ROOM_ID> …` (public site is `public …`, not a room)
+    Create {
+        /// Room slug (lowercase letters, digits, hyphens; 1–64 chars), e.g. `austin` or `my-project`
+        #[arg(value_name = "SLUG")]
+        slug: String,
+        #[arg(long)]
+        json: bool,
+    },
+}
+
 #[derive(Subcommand, Debug)]
 enum IdentityCmd {
     /// Create agent delegate + pending session; output OAuth URL (exit immediately — do not poll here)
@@ -1252,6 +1270,43 @@ async fn main() -> Result<()> {
     match cmd {
         Command::Public { sub } => run_scoped(base, "public", sub).await?,
         Command::Private { room, sub } => run_scoped(base, &room, sub).await?,
+        Command::Room { sub } => match sub {
+            RoomCmd::Create { slug, 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::RoomCreate { slug }],
+                )
+                .await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::RoomCreated { room_id } => {
+                        if json {
+                            println!(
+                                "{}",
+                                serde_json::to_string_pretty(&serde_json::json!({
+                                    "ok": true,
+                                    "room_id": room_id,
+                                }))?
+                            );
+                        } else {
+                            println!("{room_id}");
+                            println!();
+                            println!("Next: npx slugsocial private {room_id} forum post <TAG> --delegate '…' …");
+                            println!("      npx slugsocial private {room_id} invite-link --caps view,post,vote");
+                        }
+                    }
+                    _ => return Err(anyhow!("unexpected RPC result")),
+                }
+            }
+        },
 
         Command::Healthz { json } => {
             let client = http_client()?;
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index f6bbc3df71909a2da7403cd46fe4ea6ca130c692..7d384e938a526bdf6aa04d1bf21a54d3fcb57d7e 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -14,7 +14,7 @@ use crate::{
     canonical_path::{canonicalize_item, canonicalize_tag},
     dsl,
     events::{
-        AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility,
+        AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability,
     },
     identity::{parse_agent, parse_username},
     path_types::CanonicalItemUrl,
@@ -270,7 +270,7 @@ async fn rpc_post(
     let scope = scope_from_room_wire(&room_key);
 
     let is_private = !matches!(scope, ScopeId::Public);
-    if is_private && !reduced.rooms.contains_key(&room_key) {
+    if is_private && !reduced.rooms.contains(&room_key) {
         drop(reduced);
         return Err(("unknown room".into(), Some(format!("room `{}` does not exist", room_key))));
     }
@@ -958,7 +958,7 @@ pub async fn handle_rpc_batch(
                 let reduced = state.reduced.read().await;
                 line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))
             }
-            RpcCommand::RoomCreate { slug, visibility } => {
+            RpcCommand::RoomCreate { slug } => {
                 // Scope the first read so its guard drops before any nested `read().await` / `write().await`.
                 // A guard from `match verify(..., &*state.reduced.read().await)` would otherwise live for the
                 // whole `match` and deadlock here (tokio::sync::RwLock is not reentrant).
@@ -975,53 +975,42 @@ pub async fn handle_rpc_batch(
                         } else if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
                             line_err("slug must be lowercase alphanumeric with hyphens", None)
                         } else {
-                            match visibility.as_deref().unwrap_or("private") {
-                                "private" | "public" => {
-                                    let vis = if visibility.as_deref() == Some("public") {
-                                        ThreadVisibility::Public
-                                    } else {
-                                        ThreadVisibility::Private
-                                    };
-                                    let short_id = loop {
-                                        let id = gen_short_id();
-                                        if !state.reduced.read().await.rooms.contains_key(&format!("{id}/{slug}")) {
-                                            break id;
-                                        }
-                                    };
-                                    let room_id = format!("{short_id}/{slug}");
-                                    let ts = now_ms();
-                                    let tc_ev = Event::RoomCreated(RoomCreated {
-                                        ts,
-                                        room_id: room_id.clone(),
-                                        slug: slug.clone(),
-                                        owner: principal.clone(),
-                                        visibility: vis,
-                                    });
-                                    let ga_ev = Event::GrantAdded(GrantAdded {
-                                        ts,
-                                        room_id: room_id.clone(),
-                                        username: principal.clone(),
-                                        capabilities: vec![
-                                            ThreadCapability::View,
-                                            ThreadCapability::Post,
-                                            ThreadCapability::Vote,
-                                            ThreadCapability::AddItem,
-                                            ThreadCapability::Manage,
-                                        ],
-                                        granted_by: principal.clone(),
-                                    });
-                                    if let Err(e) = state.event_log.append(&tc_ev).await {
-                                        line_err(format!("{e}"), None)
-                                    } else if let Err(e) = state.event_log.append(&ga_ev).await {
-                                        line_err(format!("{e}"), None)
-                                    } else {
-                                        let mut r = state.reduced.write().await;
-                                        r.apply_event(tc_ev);
-                                        r.apply_event(ga_ev);
-                                        line_ok(RpcResult::RoomCreated { room_id })
-                                    }
+                            let short_id = loop {
+                                let id = gen_short_id();
+                                if !state.reduced.read().await.rooms.contains(&format!("{id}/{slug}")) {
+                                    break id;
                                 }
-                                other => line_err(format!("unknown visibility: {other}"), None),
+                            };
+                            let room_id = format!("{short_id}/{slug}");
+                            let ts = now_ms();
+                            let tc_ev = Event::RoomCreated(RoomCreated {
+                                ts,
+                                room_id: room_id.clone(),
+                                slug: slug.clone(),
+                                owner: principal.clone(),
+                            });
+                            let ga_ev = Event::GrantAdded(GrantAdded {
+                                ts,
+                                room_id: room_id.clone(),
+                                username: principal.clone(),
+                                capabilities: vec![
+                                    ThreadCapability::View,
+                                    ThreadCapability::Post,
+                                    ThreadCapability::Vot

… preview truncated; 10,232 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.