constitution · epochs · watch · epoch 3

comparison

c_25172cf8caa0 (tommy-mor) vs c_0a9a8eab32ba (tommy-mor)

download prompt · raw event · cmp_76b553bd70331c

council reasoning

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

A fixes a severe, user-facing bug: a shared sentinel delegate bound on first vote silently blocked every subsequent human voter, breaking core multi-user functionality; the change is minimal, clearly scoped (Option<String> agent), removes dead sentinel code, and is verified by a concrete two-user vote test. B is also solid—refactoring duplicated feed logic into one function and fixing subtle same-millisecond/clock-rollback ordering plus permission-anchor edge cases—but the bug it fixes is rarer and less catastrophic than A's outright feature-breaking defect.

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

B replaces fragile timestamp cutoffs with durable ingest-order anchors and correct permission filtering in rpc_feed, fixing missed concurrent posts, clock-rollback gaps, and private-room leaks (including room on FeedPost). A is a real multi-user vote fix by dropping shared browser sentinel delegates for Option::None, but it is a narrower auth/UI binding cleanup than B’s core feed correctness and visibility hardening.

openai/gpt-chat-latest · winner B · 3:2 · permalink

Side B refactors feed generation into a shared `rpc_feed` path and fixes a subtle correctness issue by anchoring implicit catch-up to durable ingest order instead of timestamps, while also enforcing visibility filtering and exposing room metadata in feed responses. Its integration tests cover timestamp collisions and private-room permission changes, whereas Side A primarily removes browser sentinel delegates by making pending-session agents optional to fix multi-user browser voting and simplify attribution.

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_0a9a8eab32ba (tommy-mor)

message

[c94456ff] Make feed catch-up stable and permission-aware

Anchor implicit feeds to durable ingest order and cover multi-user private-room visibility so concurrent posts are not missed or leaked.

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

diff preview

diff --git a/cli/src/main.rs b/cli/src/main.rs
index abb5a55b49f60fe28fbfd4ec02715cb94ea0b4ec..c4f1494df3aedbd8b883aea6249579aa8336abfe 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -878,6 +878,30 @@ mod tests {
             "graph: 4 items, 3/6 pairs (50.0% density), 1 component, connected"
         );
     }
+
+    #[test]
+    fn feed_without_since_uses_logged_in_delegate_from_env() {
+        let key = "SLUG_DELEGATE";
+        let previous = std::env::var_os(key);
+        let expected = "00000000-0000-0000-0000-0000000000ee:test:local/model";
+        std::env::set_var(key, expected);
+
+        let cli = Cli::try_parse_from(["slugsocial", "feed"]).expect("parse feed");
+
+        match previous {
+            Some(value) => std::env::set_var(key, value),
+            None => std::env::remove_var(key),
+        }
+        match cli.cmd {
+            Some(Command::Feed {
+                delegate, since, ..
+            }) => {
+                assert_eq!(delegate.as_deref(), Some(expected));
+                assert!(since.is_none());
+            }
+            _ => panic!("expected feed command"),
+        }
+    }
 }
 
 async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
@@ -1626,7 +1650,15 @@ async fn run() -> Result<()> {
                 } else {
                     for p in &resp.posts {
                         let ago = slug_types::timeago::timeago(now_ms, p.ts);
-                        println!("<post id=\"{}\" ts=\"{}\">", p.id, ago);
+                        let thread_attr = p
+                            .thread
+                            .as_deref()
+                            .map(|thread| format!(" thread=\"{thread}\""))
+                            .unwrap_or_default();
+                        println!(
+                            "<post id=\"{}\" ts=\"{}\" room=\"{}\"{}>",
+                            p.id, ago, p.room, thread_attr
+                        );
                         print!("{}", p.body);
                         if !p.body.ends_with('\n') { println!(); }
                         println!("</post>");
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index afc4f95bef160c1e38ecff2c096d6440cd94e2b3..46d748f918d9b225acc4ecedfe5a1793089407b5 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -72,6 +72,80 @@ fn can_view_scope(reduced: &ReducerState, scope: &ScopeId, principal: Option<&st
     }
 }
 
+/// Build a feed in durable ingest order.
+///
+/// An implicit feed boundary is an ingest position, not only its millisecond timestamp. Two users
+/// can post in the same millisecond, and wall-clock timestamps can move backwards during replay.
+/// Explicit `since` remains a timestamp query for API compatibility, but scans the whole ordered
+/// ledger rather than assuming timestamps are monotonic.
+fn rpc_feed(
+    reduced: &ReducerState,
+    viewer: &str,
+    delegate: Option<String>,
+    requested_since: Option<i64>,
+    implicit_anchor: Option<(usize, i64)>,
+    limit: usize,
+) -> FeedResponse {
+    let since = requested_since.or_else(|| implicit_anchor.map(|(_, ts)| ts));
+    let implicit_anchor_index = requested_since
+        .is_none()
+        .then(|| implicit_anchor.map(|(index, _)| index))
+        .flatten();
+
+    let matching: Vec<&str> = reduced
+        .ingests_ordered
+        .iter()
+        .enumerate()
+        .rev()
+        .filter(|(index, id)| {
+            reduced.ingests_by_id.get(id.as_str()).is_some_and(|ing| {
+                match requested_since {
+                    Some(cutoff) => ing.ts > cutoff,
+                    None => implicit_anchor_index.is_none_or(|anchor| *index > anchor),
+                }
+            })
+        })
+        .map(|(_, id)| id.as_str())
+        .filter(|id| {
+            reduced.ingests_by_id.get(*id).is_some_and(|ing| {
+                let scope = scope_from_room_wire(&ing.room_id);
+                can_view_scope(reduced, &scope, Some(viewer))
+            })
+        })
+        .filter(|id| !reduced.redacted_posts.contains(*id))
+        .collect();
+
+    let total = matching.len();
+    let posts = matching
+        .into_iter()
+        .take(limit)
+        .filter_map(|id| reduced.ingests_by_id.get(id))
+        .map(|ing| {
+            let scope = scope_from_room_wire(&ing.room_id);
+            let thread_post_index = reduced.try_thread_post_index_chronological(
+                &scope,
+                &ing.thread_tag,
+                &ing.id,
+            );
+            FeedPost {
+                ts: ing.ts,
+                id: ing.id.clone(),
+                room: ing.room_id.clone(),
+                thread: Some(ing.thread_tag.clone()),
+                thread_post_index,
+                body: ing.raw.clone(),
+            }
+        })
+        .collect();
+
+    FeedResponse {
+        delegate,
+        since,
+        posts,
+        total,
+    }
+}
+
 fn principal_from_optional_bearer(headers: &HeaderMap, reduced: &ReducerState) -> Result<Option<String>, RpcErr> {
     if headers.contains_key(axum::http::header::AUTHORIZATION) {
         verify_bearer_principal(headers, reduced)
@@ -1506,60 +1580,27 @@ pub async fn handle_rpc_batch(
                                         Some("this delegate is not bound to your signed-in account".into()),
                                     )
                                 } else {
-                                    let since_default = reduced
+                                    let implicit_anchor = reduced
                                         .ingests_ordered
                                         .iter()
+                                        .enumerate()
                                         .rev()
-                                        .filter_map(|id| reduced.ingests_by_id.get(id))
-                                        .find(|ing| {
-                                            if ing.delegate.as_deref() != Some(delegate_stored.as_str()) {
-                                                return false;
-                                            }
-                                            let scope = scope_from_room_wire(&ing.room_id);
-                                            can_view_scope(&reduced, &scope, Some(viewer.as_str()))
-                                        })
-                                        .map(|ing| ing.ts);
-                                    let since = since.or(since_default);
-                                    let cutoff = since.unwrap_or(0);
-                                    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
-                                    let matching: Vec<&str> = reduced.ingests_ordered.iter().rev()
-                                        .map(|id| id.as_str())
-                                        .take_while(|id| reduced.ingests_by_id.get(*id).is_some_and(|ing| ing.ts > cutoff))
-                                        .filter(|id| {
-                                            reduced.ingests_by_id.get(*id).is_some_and(|ing| {
-                                                let scope = scope_from_room_wire(&ing.room_id);
-                                                can_view_scope(&reduced, &scope, Some(viewer.as_str()))
+                                        .find_map(|(index, id)| {
+                                            reduced.ingests_by_id.get(id).and_then(|ing| {
+                                                (ing.delegate.as_deref()
+                                                    == Some(delegate_stored.as_str()))
+                                                .then_some((index, ing.ts))
                                             })
-                                        })
-                                        .filter(|id| !reduced.redacted_posts.contains(*id))
-                                        .collect();
-                                    let total = matching.len();
-                                    let posts: Vec<FeedPost> = matching.into_iter()
-                                        .take(limit)
-                                        .filter_map(|id| reduced.ingests_by_id.get(id))
-                                        .map(|ing| {
-                                            let scope = scope_from_room_wire(&ing.room_id);
-                                            let thread_post_index = reduced
-                                                .try_thread_post_index_chronological(
-                                                    &scope,
-                                                    &ing.thread_tag,
-                                                    &ing.id,
-                                                );
-                                            FeedPost {
-                                                ts: ing.ts,
-                                                id: ing.id.clone(),
-                                                thread: Some(ing.thread_tag.clone()),
-                                                thread_post_index,
-                                                body: ing.raw.clone(),
-                                            }
-                                        })
-                                        .collect();
-                                    line_ok(RpcResult::Feed(FeedResponse {
-                                        delegate: Some(delegate_stored),
+                                        });
+                                    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
+                                    line_ok(RpcResult::Feed(rpc_feed(
+                                        &reduced,
+                                        &viewer,
+                                        Some(delegate_stored),
                                         since,
-                                        posts,
-                                        total,
-                                    }))
+                                        implicit_anchor,
+                                        limit,
+                                    )))
                                 };
                                 drop(reduced);
                                 line
@@ -1567,60 +1608,25 @@ pub async fn handle_rpc_batch(
                             None => {
                                 // Session catch-up: last time *you* posted anything (delegate or not), so revisiting
                                 // an old chat with only a token still gets a sane cutoff.
-                                let since_default = reduced
+                                let implicit_anchor = reduced
                                     .ingests_ordered
                                     .iter()
+                                    .enumerate()
                                     .rev()
-                                    .filter_map(|id| reduced.ingests_by_id.get(id))
-                                    .find(|ing| {
-                                        if ing.principal != viewer {
-                                            return false;
-                                        }
-                                        let scope = scope_from_room_wire(&ing.room_id);
-                                        can_view_scope(&reduced, &scope, Some(viewer.as_str()))
-                                    })
-                                    .map(|ing| ing.ts);
-                                let since = since.or(since_default);
-                                let cutoff = since.unwrap_or(0);
-                                let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
-                                let matching: Vec<&str> = reduced.ingests_ordered.iter().rev()
-                                    .map(|id| id.as_str())
-                                    .take_while(|id| reduced.inges

… preview truncated; 14,758 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.