constitution · epochs · watch · epoch 3

comparison

c_25172cf8caa0 (tommy-mor) vs c_e2ee16c7ada5 (tommy-mor)

download prompt · raw event · cmp_d5be9df5f19720

council reasoning

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

Side B is a substantial, coherent refactor that removes duplicated/ad-hoc identity-canonicalization logic scattered across the codebase, introduces a clear module boundary (canonical_path.rs, identity.rs) with strict validation, and makes delegate optional at the type level end-to-end (CLI, wire types, reducer, HTML) with matching tests updated across many call sites. Side A is a smaller, real bugfix (removing shared browser sentinel delegates that blocked concurrent votes) which is valuable but narrower in scope, and it actually builds on the identity model B established, making B's foundational work arguably a prerequisite for A's fix.

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

B is a lasting identity redesign: optional naked delegates, strict no-@ wire form, identity/canonical_path split, and a reducer that stops rewriting principals—foundational across ingest, APIs, CLI, and HTML. A is a high-value but narrower follow-on bugfix (drop shared WEB_BROWSER_AGENT sentinels, Option agent on pending sessions, multi-user vote test) that depends on that model rather than establishing it.

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

Side A fixes a concrete correctness bug by replacing shared browser sentinel delegates with `None` for web sessions, preventing erroneous `AgentBound` collisions so multiple human users can vote independently. It updates the pending-session model, HTML attribution, CLI polling, and adds an end-to-end integration test verifying two different browser users can vote without creating delegate bindings, whereas Side B is primarily a broad refactor of identity/canonicalization and API conventions with comparatively less direct functional impact.

sides

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

download full diff A

B — c_e2ee16c7ada5 (tommy-mor)

message

[80ad7753] refactor: split canonical_path and identity; strict wire identity without @

- Add canonical_path.rs (tag + item URL normalization) and identity.rs
  (parse_username/parse_agent; reject @ in API input).
- Slim events.rs to event types only; reducer applies no identity rewriting.
- JSON APIs return stored-form usernames and agent ids; HTML keeps @/@@ for display.
- Optional delegate on ingest; CLI and tests use naked uuid:rig:model.

Made-with: Cursor

diff preview

diff --git a/cli/src/main.rs b/cli/src/main.rs
index 5ac8e289f2b7a366b5959d9338d02f9b546f408a..630c5dea1f78c0ec9bc53e6b96234a0dc75bb705 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -61,8 +61,8 @@ enum Command {
         /// Example: --before 2026-06-01
         #[arg(long, value_name = "DATE_OR_MS")]
         before: Option<String>,
-        /// Filter to posts from this actor (UUID prefix match).
-        /// Example: --actor 4d9d6173
+        /// Filter to posts from this principal username (prefix match, stored form).
+        /// Example: --actor alice
         #[arg(long, value_name = "PREFIX")]
         actor: Option<String>,
         /// Fetch a single post by its ingest ID (from --json output).
@@ -75,9 +75,8 @@ enum Command {
     ///
     /// SYNTAX:
     ///
-    /// Actor (required, once per document):
-    ///   @<uuid>:<rig>:<model>
-    ///   Example: @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet
+    /// Identity: human comes from the bearer token; optional AI delegate from `--delegate`
+    /// (`uuid:rig:provider/model`). The document body is DSL only (items, votes, prose) — no `@` lines.
     ///
     /// Thread (required, once per document):
     ///   #thread-tag
@@ -109,7 +108,7 @@ enum Command {
     ///   Example: ~/python > ~/rust { Python's simpler syntax reduces learning curve. }
     ///
     /// Prose (optional, anywhere):
-    ///   Any line that doesn't start with @, #, or ~ is prose.
+    ///   Any line that doesn't start with # or ~ (or `http`) is prose.
     ///   Prose is displayed in thread context but does not affect rankings or items.
     ///   Use prose to write blog posts, reasoning, or notes within your ingest.
     ///
@@ -125,8 +124,7 @@ enum Command {
     /// EXAMPLES:
     ///
     ///   # From heredoc (recommended for agents)
-    ///   npx slugsocial ingest << 'EOF'
-    ///   @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet
+    ///   npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet' << 'EOF'
     ///   #languages: Python vs Rust for systems programming
     ///
     ///   ~/languages/python { A high-level language with simple syntax and rich ecosystem. }
@@ -153,14 +151,9 @@ enum Command {
         /// Thread identifier (public tag like "languages", without #).
         #[arg(long, env = "SLUG_THREAD", default_value = "public", value_name = "THREAD")]
         thread: String,
-        /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model
-        #[arg(
-            long,
-            env = "SLUG_DELEGATE",
-            default_value = "@@00000000-0000-0000-0000-000000000000:cli:local/dev",
-            value_name = "DELEGATE"
-        )]
-        delegate: String,
+        /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.
+        #[arg(long, env = "SLUG_DELEGATE", value_name = "DELEGATE")]
+        delegate: Option<String>,
         /// Output as JSON for agent parsing
         #[arg(long)]
         json: bool,
@@ -174,14 +167,9 @@ enum Command {
         /// Thread identifier (public tag like "languages", without #).
         #[arg(long, env = "SLUG_THREAD", default_value = "public", value_name = "THREAD")]
         thread: String,
-        /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model
-        #[arg(
-            long,
-            env = "SLUG_DELEGATE",
-            default_value = "@@00000000-0000-0000-0000-000000000000:cli:local/dev",
-            value_name = "DELEGATE"
-        )]
-        delegate: String,
+        /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.
+        #[arg(long, env = "SLUG_DELEGATE", value_name = "DELEGATE")]
+        delegate: Option<String>,
         /// Output as JSON for agent parsing
         #[arg(long)]
         json: bool,
@@ -193,10 +181,10 @@ enum Command {
     /// Useful for agents to catch up on activity after a context reset.
     ///
     /// Examples:
-    ///   npx slugsocial feed @<uuid>:<rig>:<model>
-    ///   npx slugsocial feed @<uuid>:<rig>:<model> --since 2026-01-01
+    ///   npx slugsocial feed tommy
+    ///   npx slugsocial feed tommy --since 2026-01-01
     Feed {
-        /// Actor identifier (@uuid:rig:model)
+        /// Principal username (stored form)
         #[arg(value_name = "ACTOR")]
         actor: String,
         /// Override the lower bound. Accepts Unix ms or YYYY-MM-DD.
@@ -455,7 +443,7 @@ fn print_rank_history_response(resp: &slug_types::RankHistoryResponse) {
             label,
         );
         for v in &e.caused_by {
-            println!("    {} {} {} {}", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!("  (@{})", a)).unwrap_or_default());
+            println!("    {} {} {} {}", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!("  ({})", a)).unwrap_or_default());
             if !v.body.is_empty() {
                 println!("      {}", v.body.lines().next().unwrap_or(&v.body).trim());
             }
@@ -1116,7 +1104,7 @@ async fn main() -> Result<()> {
             IdentityCmd::Start { rig, model, json } => {
                 let client = http_client()?;
                 let uuid = uuid::Uuid::new_v4().to_string();
-                let delegate = format!("@@{}:{}:{}", uuid, rig, model);
+                let delegate = format!("{uuid}:{rig}:{model}");
 
                 let start: PendingSessionStartResponse = expect_json(
                     client
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index cb0faa29b834931e2c2b2f5c174c875e2e2e9346..995ce4a61d29b024c399c656134f541ecfd880cf 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -12,10 +12,8 @@ use tokio::sync::RwLock;
 
 use crate::{
     api::helpers::{api_error, now_ms, sha256_hex},
-    events::{
-        canonicalize_username, validate_agent_format, validate_username,
-        Event, TokenIssued, UserRegistered,
-    },
+    events::{Event, TokenIssued, UserRegistered},
+    identity::{parse_agent, parse_username},
     html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
     state::{AppState, PendingSession},
 };
@@ -77,9 +75,9 @@ fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result<
     Ok(username)
 }
 
-fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) {
-    // Returns: (bearer, event, canonical_username)
-    let canonical_user = canonicalize_username(username);
+/// `stored_username` must already be in persisted shape (lowercase slug, no `@`).
+fn issue_token_for_user(stored_username: &str) -> (String, TokenIssued) {
+    let username = stored_username.to_string();
     let token_id = {
         let mut id = String::new();
         let alphabet = b"abcdefghijklmnopqrstuvwxyz0123456789";
@@ -103,13 +101,13 @@ fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) {
     let bearer = format!("slug_{token_id}_{secret}");
     let event = TokenIssued {
         ts: now_ms(),
-        username: canonical_user.clone(),
+        username: username.clone(),
         token_id,
         token_hash,
         salt,
         issued_via: "oauth".to_string(),
     };
-    (bearer, event, canonical_user)
+    (bearer, event)
 }
 
 #[derive(Debug, Deserialize)]
@@ -207,7 +205,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
         s.provider = Some("google".to_string());
         s.provider_id = Some(sub.clone());
         if let Some(username) = existing {
-            let (bearer, token_event, canon_user) = issue_token_for_user(&username);
+            let (bearer, token_event) = issue_token_for_user(&username);
             // append token event
             let ev = Event::TokenIssued(token_event);
             if let Err(err) = state.event_log.append(&ev).await {
@@ -217,7 +215,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
                 let mut reduced = reduced_arc.write().await;
                 reduced.apply_event(ev);
             }
-            s.complete = Some((canon_user, bearer));
+            s.complete = Some((username, bearer));
             return Redirect::temporary(&format!("{public_url}/auth/complete")).into_response();
         }
     }
@@ -251,9 +249,10 @@ pub async fn post_choose_username(
     State(state): State<AppState>,
     Form(form): Form<ChooseUsernameForm>,
 ) -> impl IntoResponse {
-    if let Err(msg) = validate_username(&form.username) {
-        return api_error(StatusCode::BAD_REQUEST, "invalid username", Some(msg)).into_response();
-    }
+    let canon_user = match parse_username(&form.username) {
+        Ok(u) => u,
+        Err(msg) => return api_error(StatusCode::BAD_REQUEST, "invalid username", Some(msg)).into_response(),
+    };
 
     let sessions = pending_sessions(&state);
     let (provider, provider_id, agent) = {
@@ -270,7 +269,7 @@ pub async fn post_choose_username(
         (provider, provider_id, s.agent.clone())
     };
 
-    if let Err(msg) = validate_agent_format(&agent) {
+    if let Err(msg) = parse_agent(&agent) {
         return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
     }
 
@@ -280,7 +279,7 @@ pub async fn post_choose_username(
     if reduced.users_by_provider.contains_key(&provider_key) {
         return api_error(StatusCode::CONFLICT, "provider already registered", None).into_response();
     }
-    if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) {
+    if reduced.users_by_provider.values().any(|u| u == &canon_user) {
         drop(reduced);
         return choose_username_error_fragment(&form.session, "that username is taken — try another").into_response();
     }
@@ -288,12 +287,12 @@ pub async fn post_choose_username(
 
     let ur = Event::UserRegistered(UserRegistered {
         ts: now_ms(),
-        username: canonicalize_username(&form.username),
+        username: canon_user.clone(),
         provider: provider.to_lowercase(),
         provider_id: provider_id.clone(),
     });
 
-    let (bearer, ti, canon_user) = issue_token_for_user(&form.username);
+    let (bearer, ti) = issue_token_for_user(&canon_user);
     let ti_ev = Event::TokenIssued(ti);
 
     // Persist events.
@@ -325,15 +324,18 @@ pub async fn post_pending_session(
     State(state): State<AppState>,
     Json(req): Json<PendingSessionStartRequest>,
 ) -> impl IntoResponse {
-    if let Err(msg) = validate_agent_format(&req.agent) {
-        return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
-    }
+    let agent_naked = match parse_agent(&req.agent) {
+        Ok(a) => a,
+        Err(msg) => {
+            return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
+        }
+    };
     let session = format!("p_{}", uuid::Uuid::new_v4().simple());
     let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
     let login_url = format!("{public_url}/auth/login?session={}", urlencoding::encode(&session));
     let poll_url = format!("/api/v0/pending-session/{}", session);
     let s = PendingSession {
-        agent: req.agent.clone(),
+        agent: agent_naked,
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
@@ -359,7 +361,7 @@ pub async fn get_pending_session(
         return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
     };
     let (complete, user, token) = match &s.complete {
-        Some((u, t)) => (true, Some(format!("@{}", u)), Some(t.clone())),
+        Some((u, t)) => (true, Some(u.clone()), Some(t.clone())),
         None => (false, None, None),
     };
     Json(PendingSessionPollResponse {
@@ -388,7 +390,7 @@ pub async fn get_whoami(State(state): State<AppState>, headers: HeaderMap) 

… preview truncated; 62,772 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.