Side A fixes a real multi-user correctness bug (shared sentinel delegate blocking subsequent voters) with a broad, coherent change across CLI, server, types, and tests, including a targeted integration test proving the fix. Side B is a smaller, valid reducer fix (moving a zero-ratio guard earlier to avoid ghost items), which is correct but narrower in scope and impact than A's fix to a user-facing multi-tenant blocker.
constitution · epochs · watch · epoch 3
c_25ece8f92dc8 (tommy-mor) vs c_abd68b5e771a (tommy-mor)
download prompt · raw event · cmp_9848fa657c8fb8
council reasoning
A fixes a real multi-user blocker: shared WEB_BROWSER_AGENT sentinels bound on first browser vote and rejected later humans; making PendingSession.agent optional and posting UI votes with no delegate restores correct multi-user behavior and is backed by a full integration test. B is a correct but narrower reducer fix that only reorders the zero-ratio early-return so ensure_item/voted_pairs do not leave ghosts—valuable hygiene, smaller lasting scope than A.
Side A fixes a user-facing design flaw by removing shared sentinel browser delegates, changing pending sessions and browser posts to use no delegate, updating API types to `Option<String>`, and adding an integration test proving multiple human users can vote without conflicting `AgentBound` state. Side B is a good reducer bugfix that moves the zero-ratio guard before `ensure_item` and `voted_pairs` mutations to prevent ghost state, but it is narrower in scope and impact than the cross-cutting authentication and voting correctness fix in Side A.
sides
A — c_25ece8f92dc8 (tommy-mor)
message
[b7626603] 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 70435b412188a151c5e89e842a5de57f7480ddf2..a4a22724fc9e27879951f520a9241d5832982a70 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1645,8 +1645,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 bfddcce4dc1a77571d297648dd840e6c5bc194f8..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -29,12 +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";
-
/// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses.
pub const SLUG_SESSION_COOKIE: &str = "slug_session";
@@ -282,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,
@@ -549,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();
@@ -560,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
@@ -637,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,
@@ -698,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/ui_html.rs b/server/src/api/ui_html.rs
index 578e5844e86f7c2666c8e37ef9fa90c01d20d134..57a0139928b09034122398f8f3362cbdbcc5862e 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -276,7 +276,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/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 d242abff5769cc704b3c8070d7456e5788c31a45..22b16c87d5ecdb5272a017af70b5353d8c39c6e8 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_user_token(&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 votes must not create AgentBound entries: {:?}",
+ reduced.agent_bindings
+ );
}
#[tokio::test]
diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs
index 4a620eaa875e7a1145f2a4e82cc4ff2be21d5345..a5306fa2c4c9fda47e43fc0de42407b66e23eced 100644
--- a/server/tests/support/mod.rs
+++ b/server/tests/support/mod.rs
@@ -70,20 +70,25 @@ pub async fn rpc_batch(
response.json().await.unwrap()
}
-pub async fn seed_test_token(state: &AppState) {
+/// Seed a user + bearer into reducer state (not appended to the event log).
+/// Returns the `slug_<token_id>_<secret>` bearer string.
+pub async fn seed_user_token(
+ state: &AppState,
+ username: &str,
+ token_id: &str,
+ secret: &str,
+) -> String {
let registered = Event::UserRegistered(UserRegistered {
ts: 0,
- username: "testuser".to_string(),
+ username: username.to_string(),
provider: "test".to_string(),
- provider_id: "testuser".to_string(),
+ provider_id: username.to_string(),
});
- let token_id = "testtok";
- let secret = "secret";
let salt = "salt";
let token_hash = sha256_hex(&format!("{salt}:{secret}"));
let ev = Event::TokenIssued(TokenIssued {
ts: 0,
- username: "testuser".to_string(),
+ username: username.to_string(),
token_id: token_id.to_string(),
token_hash,
salt: salt.to_string(),
@@ -92,6 +97,11 @@ pub async fn seed_test_token(state: &AppState) {
let mut r = state.reduced.write().await;
r.apply_event(registered);
r.apply_event(ev);
+ format!("slug_{token_id}_{secret}")
+}
+
+pub async fn seed_test_token(state: &AppState) {
+ let _ = seed_user_token(state, "testuser", "testtok", "secret").await;
}
pub async fn create_test_server_with_state() -> (
diff --git a/test/oauth.clj b/test/oauth.clj
index 3cbf496005e906ad6c579d44ac8c4055ee60d063..6911ac34a55707acda9f9ce091c079964649a252 100644
--- a/test/oauth.clj
+++ b/test/oauth.clj
@@ -186,8 +186,9 @@
(:token poll-json))))))))
(defn fetch-bearer-token!
- "Simulate browser OAuth + username choice; returns `slug_…` bearer token.
- Ingest `--delegate` must match this agent string for `AgentBound` on first write."
+ "Simulate CLI identity OAuth + username choice; returns `slug_…` bearer token.
+ Pass `:agent` (default local/dev) when the test will CLI-ingest with `--delegate`
+ so first write can `AgentBound`. Browser UI posts use no delegate."
[base-url & {:keys [username agent] :or {username "intuser" agent default-agent}}]
(let [token (complete-registration! base-url :username username :agent agent)]
(when-not (str/starts-with? token "slug_")
diff --git a/types/src/lib.rs b/types/src/lib.rs
index 211493935d19582607f5c87fb492faf47bdc6f53..0c4295b15908ea7a2303dac4c42015ded51e4d73 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -535,7 +535,9 @@ pub struct PendingSessionStartResponse {
pub struct PendingSessionPollResponse {
pub ok: bool,
pub complete: bool,
- pub agent: String,
+ /// Present for CLI identity sessions; omitted for browser `/login` / `/join` (no delegate).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
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());
}
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.