Side A is a large but purely mechanical/incomplete refactor (RPC batch API, room scoping) with no evidence it compiles cleanly end-to-end in this diff (e.g. missing imports, unused fields), representing churn/rearchitecture rather than a targeted fix. Side B fixes a real, concrete multi-user bug (a shared sentinel AgentBound delegate blocking all but the first human voter), removes the buggy sentinel mechanism cleanly across CLI/server/types, and adds a targeted regression test proving the fix, making it a smaller but more clearly correct and valuable change.
constitution · epochs · watch · epoch 3
c_2595b6007624 (tommy-mor) vs c_25172cf8caa0 (tommy-mor)
download prompt · raw event · cmp_96201c5d3fb3a4
council reasoning
Commit A delivers a lasting architectural redesign: collapsing the scattered REST handlers (garden/forum/ingest/rank/etc.) into a single room-scoped RPC batch surface, splitting threads from permission rooms in the reducer/events model (ScopeId::Room, room_id+thread_tag on Ingest, forum_threads/ingests_by_scope_thread), and updating CLI + tests accordingly. Commit B is a correct and necessary but narrow bugfix that drops fake browser sentinel delegates so AgentBound no longer blocks subsequent human voters.
Side A introduces a substantial architectural change: it replaces many individual REST endpoints with a unified batched RPC interface, restructures the CLI around room-scoped commands, separates room and thread concepts in the event/reducer model, and updates validation, routing, tests, and data structures to support the new design. Side B fixes a real multi-user bug by removing browser sentinel delegates so browser-originated posts no longer create conflicting AgentBound entries, but it is a targeted correctness fix compared with Side A's broader, lasting platform restructuring.
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 omittedB — c_25172cf8caa0 (tommy-mor)
message
[b9476669] Remove browser sentinel delegates so multi-user votes work. Shared WEB_BROWSER_AGENT bound on first vote and blocked every later human; browser posts now use no delegate, matching forum UI. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/cli/src/main.rs b/cli/src/main.rs
index c4f1494df3aedbd8b883aea6249579aa8336abfe..af3e4fee876910a44f6f872b24821bc541a23e20 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1741,8 +1741,8 @@ async fn run() -> Result<()> {
tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;
let poll: PendingSessionPollResponse =
expect_json(client.get(&poll_url).send().await?).await?;
- if !poll.agent.trim().is_empty() {
- agent_out = Some(poll.agent.clone());
+ if let Some(a) = poll.agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ agent_out = Some(a.to_string());
}
if poll.complete {
token_out = poll.token;
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 50dc6af908412b16343829eae25154ebf4e19fca..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -29,18 +29,6 @@ use crate::{
write_cmd::WriteCmd,
};
-/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
-const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
-
-/// Agent id for `/login` browser OAuth (no CLI); must pass [`parse_agent`].
-pub const WEB_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000001:social:web/browser";
-
-/// True for well-known browser / human-form sentinel delegates (not real AI agents).
-/// HTML attribution should show the human username for these, not `@@uuid:rig:…`.
-pub fn is_browser_sentinel_delegate(agent: &str) -> bool {
- agent == WEB_BROWSER_AGENT || agent == INVITE_BROWSER_AGENT
-}
-
/// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses.
pub const SLUG_SESSION_COOKIE: &str = "slug_session";
@@ -288,7 +276,7 @@ pub async fn get_join_invite(
let session = format!("p_{}", uuid::Uuid::new_v4().simple());
let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()));
let s = PendingSession {
- agent: INVITE_BROWSER_AGENT.to_string(),
+ agent: None,
created_ts: now_ms(),
provider: None,
provider_id: None,
@@ -555,7 +543,7 @@ pub async fn post_choose_username(
};
let sessions = pending_sessions(&state);
- let (provider, provider_id, agent) = {
+ let (provider, provider_id) = {
let sessions_read = sessions.read().await;
let Some(s) = sessions_read.get(&form.session) else {
return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
@@ -566,14 +554,9 @@ pub async fn post_choose_username(
let Some(provider_id) = s.provider_id.clone() else {
return js_form_error_fragment(&form.session, "oauth not completed").into_response();
};
- (provider, provider_id, s.agent.clone())
+ (provider, provider_id)
};
- if let Err(msg) = parse_agent(&agent) {
- return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}"))
- .into_response();
- }
-
let redeem_invite = {
let sessions_read = sessions.read().await;
sessions_read
@@ -643,7 +626,8 @@ pub async fn get_web_login(
let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()))
.or_else(|| Some("/".to_string()));
let s = PendingSession {
- agent: WEB_BROWSER_AGENT.to_string(),
+ // Humans sign in via the website with no AI delegate.
+ agent: None,
created_ts: now_ms(),
provider: None,
provider_id: None,
@@ -704,7 +688,7 @@ pub async fn post_pending_session(
);
let poll_url = format!("/api/v0/pending-session/{session}");
let s = PendingSession {
- agent: agent_naked,
+ agent: Some(agent_naked),
created_ts: now_ms(),
provider: None,
provider_id: None,
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index fdff6db0472b36cd7870a4be68574023e0daf120..920e967b47852ea82fa61b84c457ae3582dd9800 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -18,13 +18,11 @@ pub use auth::{
get_choose_username,
get_web_login,
get_logout,
- is_browser_sentinel_delegate,
optional_principal,
resolve_web_session,
session_cookie_header_value,
WebSession,
SLUG_SESSION_COOKIE,
- WEB_BROWSER_AGENT,
};
pub use helpers::{
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 611956d0a46062b49ce350be176e4be139312ff0..6e56c039a184953cd1d0593c34cf4d5abe41f2a3 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -277,7 +277,7 @@ async fn dispatch_ui_action(
&session.bearer,
room.clone(),
thread_tag.clone(),
- Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),
+ None,
text,
)
.await
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 645d54f2e117ba901e02ed958ada6ff69a78ae34..03ad762e88709c77b9d5dd130c48bc96226616a1 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -560,27 +560,28 @@ fn identity_color_css(seed: &str) -> String {
format!("hsl({hue}, 62%, 66%)")
}
-/// Seed for author color: real AI → delegate uuid; human/sentinel → principal username.
+/// Seed for author color: AI → delegate uuid; human (no delegate) → principal username.
fn authorship_color_seed<'a>(principal: &'a str, delegate: &'a Option<String>) -> &'a str {
match delegate {
- Some(d) if !crate::api::is_browser_sentinel_delegate(d) => {
- d.split(':').next().filter(|s| !s.is_empty()).unwrap_or(d.as_str())
- }
- _ => principal,
+ Some(d) => d
+ .split(':')
+ .next()
+ .filter(|s| !s.is_empty())
+ .unwrap_or(d.as_str()),
+ None => principal,
}
}
-/// Prefer the AI delegate in attribution; fall back to the human username when there is no
-/// delegate or the delegate is a browser/human-form sentinel.
+/// Prefer the AI delegate in attribution; humans post with no delegate and show `@username`.
pub(crate) fn authorship_attr(principal: &str, delegate: &Option<String>) -> AuthorshipAttr {
let color = identity_color_css(authorship_color_seed(principal, delegate));
match delegate {
- Some(d) if !crate::api::is_browser_sentinel_delegate(d) => AuthorshipAttr {
+ Some(d) => AuthorshipAttr {
label: format!("@@{}", actor_label(d)),
author_title: Some(format!("@{principal}")),
color,
},
- _ => AuthorshipAttr {
+ None => AuthorshipAttr {
label: format!("@{principal}"),
author_title: None,
color,
@@ -933,7 +934,6 @@ pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {
#[cfg(test)]
mod authorship_tests {
use super::*;
- use crate::api::WEB_BROWSER_AGENT;
#[test]
fn human_or_missing_delegate_shows_username() {
@@ -943,15 +943,6 @@ mod authorship_tests {
assert!(a.color.starts_with("hsl("));
}
- #[test]
- fn browser_sentinel_delegate_shows_username() {
- let d = Some(WEB_BROWSER_AGENT.to_string());
- let a = authorship_attr("alice", &d);
- assert_eq!(a.label, "@alice");
- assert_eq!(a.author_title, None);
- assert_eq!(authorship_address("alice", &d), "@alice");
- }
-
#[test]
fn real_ai_delegate_shows_short_agent_with_username_hover() {
let d = Some(
diff --git a/server/src/state.rs b/server/src/state.rs
index 648ab5304764a329fcabbbbcd3782b94e3e005a8..8ea84dcf9b1df8f8037e913cdd94e5908e6d5d55 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -21,7 +21,8 @@ pub struct InviteState {
#[derive(Debug, Clone)]
pub struct PendingSession {
- pub agent: String,
+ /// CLI `identity start` delegate (`uuid:rig:model`). `None` for browser `/login` and `/join`.
+ pub agent: Option<String>,
pub created_ts: i64,
pub provider: Option<String>,
pub provider_id: Option<String>,
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index 6b1475b773106a2dd3f326475c9fb4cc727f6b4b..714563a8b330e3917d95a54ae29b4de143e3b8a4 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -418,6 +418,99 @@ async fn test_web_login_carries_vote_pair_next_into_pending_session() {
let sessions = state.pending_sessions.read().await;
let pending = sessions.get(&session).expect("pending session");
assert_eq!(pending.redirect_next.as_deref(), Some(next));
+ assert_eq!(
+ pending.agent, None,
+ "browser /login must not invent a sentinel delegate"
+ );
+}
+
+#[tokio::test]
+async fn test_vote_compare_two_users_both_succeed_without_delegate() {
+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+ let client = reqwest::Client::new();
+ let alice = test_bearer();
+ let bob = seed_test_identity(&state, "bob", "bobtok", "bobsecret").await;
+
+ // Define items first (votes require existing item bodies).
+ let seed = ui_post_ingest_rpc(
+ "public",
+ "multi-vote",
+ "~/multi-a {alpha}\n~/multi-b {beta}\n",
+ );
+ let seed_resp = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {alice}"))
+ .form(&[("__rpc__", seed.as_str())])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(seed_resp.status(), reqwest::StatusCode::OK);
+ let seed_js = seed_resp.text().await.unwrap();
+ assert!(
+ !seed_js.contains("auth-error"),
+ "item seed must succeed, got: {seed_js}"
+ );
+
+ for (bearer, left, right, explanation) in [
+ (&alice, "3", "1", "alice prefers a"),
+ (&bob, "1", "3", "bob prefers b"),
+ ] {
+ let rpc = ui_vote_compare_post_rpc(
+ "public",
+ "multi-vote",
+ "~/multi-a",
+ "~/multi-b",
+ left,
+ right,
+ explanation,
+ );
+ let resp = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .form(&[("__rpc__", rpc.as_str())])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), reqwest::StatusCode::OK);
+ let js = resp.text().await.unwrap();
+ assert!(
+ !js.contains("delegate already bound"),
+ "human vote must not hit shared-sentinel AgentBound ({explanation}), got: {js}"
+ );
+ assert!(
+ !js.contains("auth-error"),
+ "human vote must succeed ({explanation}), got: {js}"
+ );
+ assert!(
+ js.contains("vote-edge-history-region"),
+ "vote should morph edge history ({explanation}), got: {js}"
+ );
+ }
+
+ let reduced = state.reduced.read().await;
+ let human_votes: Vec<_> = reduced
+ .ingests_ordered
+ .iter()
+ .filter_map(|id| reduced.ingests_by_id.get(id))
+ .filter(|ing| ing.raw.contains("prefers"))
+ .collect();
+ assert_eq!(human_votes.len(), 2, "expected two vote ingests");
+ let mut principals: Vec<&str> = human_votes.iter().map(|i| i.principal.as_str()).collect();
+ principals.sort();
+ assert_eq!(principals, ["bob", "testuser"]);
+ for ing in &human_votes {
+ assert!(
+ ing.delegate.is_none(),
+ "browser votes must have no delegate, principal={} delegate={:?}",
+ ing.principal,
+ ing.delegate
+ );
+ }
+ assert!(
+ reduced.agent_bindings.is_empty(),
+ "human
… preview truncated; 3,004 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.