constitution · epochs · watch · epoch 3

comparison

c_f7f1b6183703 (tommy-mor) vs c_8af10ca56dff (tommy-mor)

download prompt · raw event · cmp_baefc4de99f364

council reasoning

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

B fixes a real security/design flaw (feed cutoff keyed by unauthenticated actor string) with an auth-bound delegate model, adds thorough integration tests covering rejection paths, and updates CLI/docs consistently. A is a substantial refactor adding children-fetch support and SSE-as-JS-eval, which is useful but more speculative/UI-plumbing with less clear-cut correctness payoff and more churn (rewriting SSE protocol, JS handling) than B's targeted security fix.

~x-ai/grok-latest · winner A · 3:1 · permalink

Commit A delivers substantial product capability: separate self/children Reddit fetch kinds, children listing import with tree linking, OAuth token vs API base split, SSE unified to eval JS morphs, and ranking_panel by connected components—backed by fixtures and tests. Commit B is a focused, correct feed redesign (delegate scoping, auth/binding checks, principal-wide catch-up) with solid tests, but it is a narrower API/CLI behavioral fix versus A’s multi-layer feature and protocol work.

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

Side A delivers a substantial functional expansion by introducing separate fetch modes for entities versus children, wiring the new fetch kind through the UI, SSE pipeline, Reddit worker, state management, and adding child-import parsing/linking plus updated ranking behavior for imported children. Side B makes an important API/security improvement by scoping feed retrieval to authenticated delegates and adding authorization checks and tests, but it is narrower in scope than A's end-to-end feature and architectural changes.

sides

A — c_f7f1b6183703 (tommy-mor)

message

[902a4c5c] refactor

diff preview

diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 7af6527d03c483f33f3469ce6766c01a554c5fe3..d1defd28242fd2ca3b886adc91a7070bef75e653 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -56,12 +56,9 @@ pub async fn post_ui_html(
                 return ui_js_warn(&e).into_response();
             }
             let tree = state.tree.read().await;
-            let empty = crate::reducer::GroupState::new();
-            let group = tree
-                .get(&parent)
-                .map(|n| &n.local_ranking)
-                .unwrap_or(&empty);
-            let panel = ranking_panel(&parent, group);
+            let empty = crate::reducer::NodeState::default();
+            let node = tree.get(&parent).unwrap_or(&empty);
+            let panel = ranking_panel(&parent, node);
             JsBuilder::new()
                 .morph_selector("#ranking-panel", panel)
                 .into_response()
@@ -88,9 +85,9 @@ pub async fn post_ui_html(
                     .into_response()
             }
         },
-        HtmlUiAction::FetchEntity { item } => {
+        HtmlUiAction::FetchEntity { item, kind } => {
             let id = parse_item_param(&item);
-            fetch::fetch_entity_stream(state, id).into_response()
+            fetch::fetch_entity_stream(state, id, kind).into_response()
         }
     }
 }
diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs
index 63634508496e224c38b9ec0308b7a6086462f925..9b6c2d0964640a157bca2a6a64cb0820238d8e4f 100644
--- a/server/src/fetch/html.rs
+++ b/server/src/fetch/html.rs
@@ -5,7 +5,7 @@ use maud::{html, Markup};
 use crate::{
     form_template::template_json_compact,
     path_types::ItemId,
-    reddit::is_fetchable,
+    reddit::{is_children_fetchable, is_fetchable},
     reducer::NodeState,
     ui_action::UI_RPC_FIELD,
 };
@@ -26,25 +26,16 @@ fn entity_panel(node: &NodeState) -> Markup {
     }
 }
 
-/// Reddit/API import — `POST /ui` with `fetch_entity` returns an SSE stream.
-pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
-    if !is_fetchable(item) {
-        return html! {};
-    }
-    let label = if fetching {
-        "Fetching…"
-    } else if has_data {
-        "Fetch more"
-    } else {
-        "Fetch from Reddit"
-    };
+/// One `fetch_entity` form/button targeting `kind` ("self" or "children").
+fn fetch_button(item: &ItemId, kind: &str, label: &str, fetching: bool) -> Markup {
     let rpc = template_json_compact(&serde_json::json!({
         "action": "fetch_entity",
         "item": item.as_str(),
+        "kind": kind,
     }))
     .expect("fetch_entity rpc template");
     html! {
-        form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
+        form method="post" action="/ui" class="fetch-entity-form" {
             input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
             @if fetching {
                 button type="submit" class="btn-secondary" disabled { (label) }
@@ -55,6 +46,33 @@ pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Mark
     }
 }
 
+/// Reddit/API import controls — `POST /ui` with `fetch_entity` returns an SSE
+/// stream whose events are JS snippets to `eval`.
+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
+    let self_ok = is_fetchable(item);
+    let children_ok = is_children_fetchable(item);
+    if !self_ok && !children_ok {
+        return html! {};
+    }
+    let self_label = if fetching {
+        "Fetching…"
+    } else if has_data {
+        "Refresh this"
+    } else {
+        "Fetch from Reddit"
+    };
+    html! {
+        div id="fetch-controls" class="fetch-controls" {
+            @if self_ok {
+                (fetch_button(item, "self", self_label, fetching))
+            }
+            @if children_ok {
+                (fetch_button(item, "children", if fetching { "Fetching…" } else { "Fetch posts" }, fetching))
+            }
+        }
+    }
+}
+
 /// Entity card + fetch control (target `#entity-section` for Idiomorph / SSE).
 pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {
     let has_data = node.data.is_some();
diff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs
index 2290f9d3a0f1cbf1806c6339f82a4515c11cc3d3..0177bb161cea1b72a100b52efdfc5e710271c9eb 100644
--- a/server/src/fetch/mod.rs
+++ b/server/src/fetch/mod.rs
@@ -1,4 +1,8 @@
 //! Entity import over `POST /ui` as SSE (Reddit worker in [`crate::reddit`]).
+//!
+//! Each SSE event's `data` is a JS snippet that the browser `eval`s — the same
+//! Idiomorph-morph snippets the non-streaming `/ui` responses use. There is no
+//! bespoke JSON envelope; the client just evals whatever each event carries.
 
 pub mod html;
 
@@ -8,14 +12,15 @@ use std::time::Duration;
 use async_stream::stream;
 use axum::response::sse::{Event, KeepAlive, Sse};
 use futures_util::Stream;
-use serde::Serialize;
 use tokio::sync::oneshot;
 
 use crate::{
+    html::{ranking_panel, JsBuilder},
     path_types::ItemId,
-    reddit::FetchJobResult,
+    reddit::{FetchJobResult, FetchKind},
     reducer::NodeState,
     state::AppState,
+    ui_action::FetchTarget,
 };
 
 pub fn now_ms() -> i64 {
@@ -25,55 +30,62 @@ pub fn now_ms() -> i64 {
     t.as_millis() as i64
 }
 
-#[derive(Serialize)]
-struct SseMorphPayload {
-    selector: &'static str,
-    html: String,
+fn js_event(js: String) -> Event {
+    Event::default().data(js)
 }
 
-fn morph_complete_event(html: maud::Markup) -> Event {
-    let payload = SseMorphPayload {
-        selector: "#entity-section",
-        html: html.into_string(),
-    };
-    let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into());
-    Event::default().event("complete").data(data)
+/// JS that surfaces a transient message in the page's `#errors` region.
+fn error_js(message: &str) -> String {
+    JsBuilder::new()
+        .morph_selector(
+            "#errors",
+            maud::html! { div id="errors" { p class="muted" { (message) } } },
+        )
+        .build()
 }
 
-/// Stream `fetching` → `complete` / `error` for [`crate::ui_action::HtmlUiAction::FetchEntity`].
+/// Stream Idiomorph-morph JS snippets for [`crate::ui_action::HtmlUiAction::FetchEntity`].
 pub fn fetch_entity_stream(
     state: AppState,
     id: ItemId,
+    target: FetchTarget,
 ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
-    tracing::debug!(item = %id, "fetch entity stream opened");
+    let kind = match target {
+        FetchTarget::SelfEntity => FetchKind::SelfEntity,
+        FetchTarget::Children => FetchKind::Children,
+    };
+    tracing::debug!(item = %id, ?kind, "fetch entity stream opened");
 
     let stream = stream! {
         if id.is_root() {
-            yield Ok(Event::default().event("error").data("{\"message\":\"nothing to fetch for the root\"}"));
+            yield Ok(js_event(error_js("Nothing to fetch for the root.")));
             return;
         }
 
-        if !crate::reddit::is_fetchable(&id) {
-            tracing::debug!(item = %id, "fetch stream: not fetchable");
-            yield Ok(Event::default().event("error").data("{\"message\":\"this page cannot be fetched from Reddit\"}"));
+        let fetchable = match kind {
+            FetchKind::SelfEntity => crate::reddit::is_fetchable(&id),
+            FetchKind::Children => crate::reddit::is_children_fetchable(&id),
+        };
+        if !fetchable {
+            tracing::debug!(item = %id, ?kind, "fetch stream: not fetchable");
+            yield Ok(js_event(error_js("This page cannot be fetched from Reddit.")));
             return;
         }
 
-        let fetching_html = {
+        // Optimistic "Fetching…" morph of the entity section.
+        let fetching_js = {
             let tree = state.tree.read().await;
             let empty = NodeState::default();
             let node = tree.get(&id).unwrap_or(&empty);
-            html::entity_section(&id, node, true).into_string()
+            JsBuilder::new()
+                .morph_selector("#entity-section", html::entity_section(&id, node, true))
+                .build()
         };
-        let fetching_payload = serde_json::json!({
-            "selector": "#entity-section",
-            "html": fetching_html,
-        });
-        yield Ok(Event::default().event("fetching").data(fetching_payload.to_string()));
+        yield Ok(js_event(fetching_js));
 
         let (tx, rx) = oneshot::channel();
-        state.reddit.request_fetch(id.clone(), true, Some(tx));
-        tracing::debug!(item = %id, "fetch stream: queued reddit job");
+        state.queue_entity_fetch(id.clone(), kind, Some(tx));
+        tracing::debug!(item = %id, ?kind, "fetch stream: queued reddit job");
 
         let result = match rx.await {
             Ok(r) => r,
@@ -82,31 +94,42 @@ pub fn fetch_entity_stream(
                 FetchJobResult::Failed("reddit worker stopped".into())
             }
         };
-
         tracing::debug!(item = %id, ?result, "fetch stream: job finished");
 
         match result {
-            FetchJobResult::Imported | FetchJobResult::NotFound => {
+            FetchJobResult::Imported(_)
+            | FetchJobResult::NotFound
+            | FetchJobResult::SkippedCached
+            | FetchJobResult::SkippedDuplicate => {
                 let tree = state.tree.read().await;
                 let empty = NodeState::default();
                 let node = tree.get(&id).unwrap_or(&empty);
-                yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
+                let mut b = JsBuilder::new()
+                    .morph_selector("#entity-section", html::entity_section(&id, node, false));
+                if kind == FetchKind::Children {
+                    b = b.morph_selector("#ranking-panel", ranking_panel(&id, node));
+                }
+                yield Ok(js_event(b.build()));
             }
-            FetchJobResult::SkippedCached | FetchJobResult::SkippedDuplicate => {
+            FetchJobResult::RateLimited { reset_secs } => {
                 let tree = state.tree.read().await;
                 let empty = NodeState::default();
                 let node = tree.get(&id).unwrap_or(&empty);
-                yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
-            }
-            FetchJobResult::RateLimited { reset_secs } => {
-                yield Ok(Event::default().event("error").data(
-                    serde_json::json!({"message": format!("Reddit rate limit — retry in {reset_secs}s")}).to_string(),
-                ));
+                let js = JsBuilder::new()
+                    .morph_selector("#entity-section", html::entity_section(&id, node, false))
+                    .raw(&error_js(&format!("Reddit rate limit — retry in {reset_secs}s.")))
+                    .build();
+                yield Ok(js_event(js));
             }
             FetchJobResult::Failed(msg) => {
-                yield Ok(Event::default().event("error").data(
-                    serde_json::json!({"message": msg}).to_string(),
-                ));
+                let tree = state.tree.read().await;
+                let empty = NodeState::default();
+                let node = tree.get(&id).unwrap_or(&empty);
+                let js = JsBuilder::new()
+                    .morph_selector("#entity-section", html::entity_section(&id, node, false))
+                    .raw(&error_js(&format!("Fetch failed: {msg}")))
+                    .build();
+                yield Ok(js_event(js));
             }
         }
     };
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 9314a7556306ddab969b896dbf4126b542a46722..27ce9118c73ec5643e04363ab1a36cf5da6101bf 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -6,12 +6,16 @@ use axum::{
 };
 use maud::{html, Markup, DOCTYPE};
 
+use std::collections::HashSet;


… preview truncated; 29,086 characters omitted

download full diff A

B — c_8af10ca56dff (tommy-mor)

message

[951dac0e] Feed RPC/CLI: scope by agent delegate instead of principal (#108)

* feat(feed): key GetFeed by agent delegate, not principal

- RpcCommand::GetFeed and FeedResponse use delegate (uuid:rig:model)
- Accept legacy JSON field name "actor" via serde alias
- Default cutoff finds last ingest with matching delegate
- CLI feed subcommand takes DELEGATE; docs updated
- Integration: ACL test uses post delegate; add delegate scoping test

Co-authored-by: tommy <thmorriss@gmail.com>

* fix(feed): require auth and bound delegate; drop actor JSON alias

- GetFeed rejects missing Authorization and delegates not bound to bearer principal
- CLI feed sends saved bearer token like other authenticated RPCs
- Integration: assert cross-user delegate rejected; no-auth rejected

Co-authored-by: tommy <thmorriss@gmail.com>

* fix(feed): principal-wide catch-up includes delegate posts; optional CLI delegate

- GetFeed without delegate: since = last ingest by bearer principal (any delegate)
- CLI: optional positional delegate, defaults from SLUG_DELEGATE like forum post
- Integration test for delegate-only posting + argless feed bounded since

Co-authored-by: tommy <thmorriss@gmail.com>

---------

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

diff preview

diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index 9828cba4d9c17b7cce3de597d8724609b2b2adbe..59782bd1140dc9f31d44965bace833ea562e113e 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -171,8 +171,9 @@ EOF
 # See current ranking
 npx slugsocial public garden children languages --json
 
-# After a context reset: catch up (feed is keyed by principal username, stored form)
-npx slugsocial feed yourusername
+# Catch up: no delegate arg → since your last post as this user (any delegate). Or pass the chat's uuid:rig:model (or SLUG_DELEGATE).
+npx slugsocial feed
+npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5
 }
 
 ~/commands {
@@ -207,8 +208,9 @@ identity poll <session>                           Complete OAuth; saves bearer t
 
 whoami [--json]                                 Resolve saved bearer token to principal
 
-feed <username>                                 Activity since your last post (stored username, no @)
-feed <username> --since 2026-01-01              Override lower bound (Unix ms or YYYY-MM-DD)
+feed                                            Activity since you last posted (principal-wide)
+feed <uuid:rig:model>                           Activity since this delegate last posted (or SLUG_DELEGATE)
+feed … --since 2026-01-01                       Override lower bound (Unix ms or YYYY-MM-DD)
 
 search <query>                                  Search items, threads, posts (public index)
 
diff --git a/cli/src/main.rs b/cli/src/main.rs
index b008cf377bb360aaae666d2dfd4b5e13dedb204a..122ee42189b2a6e47137be594556f8dddb7989a9 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -146,20 +146,27 @@ enum Command {
         sub: RoomCmd,
     },
 
-    /// Show all activity since you last posted (global feed)
+    /// Show activity since your last post, or since this delegate last posted (global feed)
     ///
-    /// Returns all ingests since this actor's last ingest, newest first.
-    /// Useful for agents to catch up on activity after a context reset.
+    /// With **no delegate argument**: uses the timestamp of your **last ingest as this principal** (any
+    /// `uuid:rig:model` or none), so an old chat that only has your token still gets a sane catch-up
+    /// after you have been posting with `--delegate`.
+    ///
+    /// With a **delegate** (or `SLUG_DELEGATE`): cutoff is that delegate's last ingest only — use the same
+    /// string as in that chat for per-session continuity.
+    ///
+    /// Requires your saved bearer token; an explicit delegate must be bound to your account.
     ///
     /// Examples:
-    ///   npx slugsocial feed tommy
-    ///   npx slugsocial feed tommy --since 2026-01-01
+    ///   npx slugsocial feed
+    ///   npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5
+    ///   npx slugsocial feed --since 2026-01-01
     Feed {
-        /// Principal username (stored form)
-        #[arg(value_name = "ACTOR")]
-        actor: String,
+        /// Agent delegate (`uuid:rig:provider/model`); omit for principal-wide catch-up. Same env as forum post.
+        #[arg(value_name = "DELEGATE", env = "SLUG_DELEGATE")]
+        delegate: Option<String>,
         /// Override the lower bound. Accepts Unix ms or YYYY-MM-DD.
-        /// Defaults to the actor's last ingest timestamp on the server.
+        /// Defaults to the delegate's last ingest timestamp on the server.
         #[arg(long, value_name = "DATE_OR_MS")]
         since: Option<String>,
         /// Max items to return (default: 10)
@@ -1434,14 +1441,25 @@ async fn main() -> Result<()> {
             }
         }
 
-        Command::Feed { actor, since, limit, json } => {
+        Command::Feed { delegate, since, limit, 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 delegate = delegate
+                .as_deref()
+                .map(str::trim)
+                .filter(|s| !s.is_empty())
+                .map(|s| s.to_string());
             let batch = send_rpc(
                 &client,
                 base,
-                None,
+                Some(&bearer),
                 vec![RpcCommand::GetFeed {
-                    actor,
+                    delegate,
                     since: match since {
                         Some(s) => Some(parse_ts(&s)?),
                         None => None,
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 422b520f527f64571f86f0ee3cb57edcbc21c0c3..936f55bac6abb69e672f6594385934688dc215e3 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -1785,30 +1785,105 @@ pub async fn handle_rpc_batch(
                 }
             },
             RpcCommand::GetFeed {
-                actor,
+                delegate,
                 since,
                 limit,
             } => {
                 const DEFAULT_LIMIT: usize = 50;
                 const MAX_LIMIT: usize = 200;
-                match parse_username(&actor) {
-                    Err(msg) => line_err("invalid actor", Some(msg)),
-                    Ok(actor_stored) => {
-                        let reduced = state.reduced.read().await;
-                        match principal_from_optional_bearer(&headers, &reduced) {
-                            Err((e, h)) => line_err(e, h),
-                            Ok(viewer) => {
+                let reduced = state.reduced.read().await;
+                match verify_bearer_principal(&headers, &reduced) {
+                    Err((_, m)) => line_err(m, None),
+                    Ok(viewer) => {
+                        let delegate_parsed: Option<Result<String, String>> = delegate
+                            .as_deref()
+                            .map(str::trim)
+                            .filter(|s| !s.is_empty())
+                            .map(parse_agent);
+                        match delegate_parsed {
+                            Some(Err(msg)) => {
+                                drop(reduced);
+                                line_err("invalid delegate", Some(msg))
+                            }
+                            Some(Ok(delegate_stored)) => {
+                                let line = if reduced.agent_bindings.get(&delegate_stored) != Some(&viewer) {
+                                    line_err(
+                                        "not your delegate",
+                                        Some("this delegate is not bound to your signed-in account".into()),
+                                    )
+                                } else {
+                                    let since_default = reduced
+                                        .ingests_ordered
+                                        .iter()
+                                        .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).map_or(false, |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()))
+                                            })
+                                        })
+                                        .collect();
+                                    let total = matching.len();
+                                    let posts: Vec<FeedPost> = matching.into_iter()
+                                        .take(limit)
+                                        .filter_map(|id| reduced.ingests_by_id.get(id))
+                                        .filter(|ing| !reduced.redacted_posts.contains(&ing.id))
+                                        .map(|ing| {
+                                            let scope = scope_from_room_wire(&ing.room_id);
+                                            let thread_post_index = reduced
+                                                .ingests_by_scope_thread
+                                                .get(&(scope, ing.thread_tag.clone()))
+                                                .and_then(|q| {
+                                                    q.iter().rev().position(|pid| pid == &ing.id).map(|i| i + 1)
+                                                });
+                                            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),
+                                        since,
+                                        posts,
+                                        total,
+                                    }))
+                                };
+                                drop(reduced);
+                                line
+                            }
+                            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
                                     .ingests_ordered
                                     .iter()
                                     .rev()
                                     .filter_map(|id| reduced.ingests_by_id.get(id))
                                     .find(|ing| {
-                                        if ing.principal != actor_stored {
+                                        if ing.principal != viewer {
                                             return false;
                                         }
                                         let scope = scope_from_room_wire(&ing.room_id);
-                                        can_view_scope(&reduced, &scope, viewer.as_deref())
+                                        can_view_scope(&reduced, &scope, Some(viewer.as_st

… preview truncated; 9,880 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.