{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[951dac0e] Feed RPC/CLI: scope by agent delegate instead of principal (#108)\n\n* feat(feed): key GetFeed by agent delegate, not principal\n\n- RpcCommand::GetFeed and FeedResponse use delegate (uuid:rig:model)\n- Accept legacy JSON field name \"actor\" via serde alias\n- Default cutoff finds last ingest with matching delegate\n- CLI feed subcommand takes DELEGATE; docs updated\n- Integration: ACL test uses post delegate; add delegate scoping test\n\nCo-authored-by: tommy \n\n* fix(feed): require auth and bound delegate; drop actor JSON alias\n\n- GetFeed rejects missing Authorization and delegates not bound to bearer principal\n- CLI feed sends saved bearer token like other authenticated RPCs\n- Integration: assert cross-user delegate rejected; no-auth rejected\n\nCo-authored-by: tommy \n\n* fix(feed): principal-wide catch-up includes delegate posts; optional CLI delegate\n\n- GetFeed without delegate: since = last ingest by bearer principal (any delegate)\n- CLI: optional positional delegate, defaults from SLUG_DELEGATE like forum post\n- Integration test for delegate-only posting + argless feed bounded since\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide A — unified diff (full patch):\ndiff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter\nindex 9828cba4d9c17b7cce3de597d8724609b2b2adbe..59782bd1140dc9f31d44965bace833ea562e113e 100644\n--- a/cli/GUIDE.sorter\n+++ b/cli/GUIDE.sorter\n@@ -171,8 +171,9 @@ EOF\n # See current ranking\n npx slugsocial public garden children languages --json\n \n-# After a context reset: catch up (feed is keyed by principal username, stored form)\n-npx slugsocial feed yourusername\n+# 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).\n+npx slugsocial feed\n+npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5\n }\n \n ~/commands {\n@@ -207,8 +208,9 @@ identity poll Complete OAuth; saves bearer t\n \n whoami [--json] Resolve saved bearer token to principal\n \n-feed Activity since your last post (stored username, no @)\n-feed --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)\n+feed Activity since you last posted (principal-wide)\n+feed Activity since this delegate last posted (or SLUG_DELEGATE)\n+feed … --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)\n \n search Search items, threads, posts (public index)\n \ndiff --git a/cli/src/main.rs b/cli/src/main.rs\nindex b008cf377bb360aaae666d2dfd4b5e13dedb204a..122ee42189b2a6e47137be594556f8dddb7989a9 100644\n--- a/cli/src/main.rs\n+++ b/cli/src/main.rs\n@@ -146,20 +146,27 @@ enum Command {\n sub: RoomCmd,\n },\n \n- /// Show all activity since you last posted (global feed)\n+ /// Show activity since your last post, or since this delegate last posted (global feed)\n ///\n- /// Returns all ingests since this actor's last ingest, newest first.\n- /// Useful for agents to catch up on activity after a context reset.\n+ /// With **no delegate argument**: uses the timestamp of your **last ingest as this principal** (any\n+ /// `uuid:rig:model` or none), so an old chat that only has your token still gets a sane catch-up\n+ /// after you have been posting with `--delegate`.\n+ ///\n+ /// With a **delegate** (or `SLUG_DELEGATE`): cutoff is that delegate's last ingest only — use the same\n+ /// string as in that chat for per-session continuity.\n+ ///\n+ /// Requires your saved bearer token; an explicit delegate must be bound to your account.\n ///\n /// Examples:\n- /// npx slugsocial feed tommy\n- /// npx slugsocial feed tommy --since 2026-01-01\n+ /// npx slugsocial feed\n+ /// npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5\n+ /// npx slugsocial feed --since 2026-01-01\n Feed {\n- /// Principal username (stored form)\n- #[arg(value_name = \"ACTOR\")]\n- actor: String,\n+ /// Agent delegate (`uuid:rig:provider/model`); omit for principal-wide catch-up. Same env as forum post.\n+ #[arg(value_name = \"DELEGATE\", env = \"SLUG_DELEGATE\")]\n+ delegate: Option,\n /// Override the lower bound. Accepts Unix ms or YYYY-MM-DD.\n- /// Defaults to the actor's last ingest timestamp on the server.\n+ /// Defaults to the delegate's last ingest timestamp on the server.\n #[arg(long, value_name = \"DATE_OR_MS\")]\n since: Option,\n /// Max items to return (default: 10)\n@@ -1434,14 +1441,25 @@ async fn main() -> Result<()> {\n }\n }\n \n- Command::Feed { actor, since, limit, json } => {\n+ Command::Feed { delegate, since, limit, json } => {\n let client = http_client()?;\n+ let bearer = effective_bearer().ok_or_else(|| {\n+ anyhow!(\n+ \"no bearer token: run `slugsocial identity start --rig --model ` \\\n+ then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token\"\n+ )\n+ })?;\n+ let delegate = delegate\n+ .as_deref()\n+ .map(str::trim)\n+ .filter(|s| !s.is_empty())\n+ .map(|s| s.to_string());\n let batch = send_rpc(\n &client,\n base,\n- None,\n+ Some(&bearer),\n vec![RpcCommand::GetFeed {\n- actor,\n+ delegate,\n since: match since {\n Some(s) => Some(parse_ts(&s)?),\n None => None,\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 422b520f527f64571f86f0ee3cb57edcbc21c0c3..936f55bac6abb69e672f6594385934688dc215e3 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -1785,30 +1785,105 @@ pub async fn handle_rpc_batch(\n }\n },\n RpcCommand::GetFeed {\n- actor,\n+ delegate,\n since,\n limit,\n } => {\n const DEFAULT_LIMIT: usize = 50;\n const MAX_LIMIT: usize = 200;\n- match parse_username(&actor) {\n- Err(msg) => line_err(\"invalid actor\", Some(msg)),\n- Ok(actor_stored) => {\n- let reduced = state.reduced.read().await;\n- match principal_from_optional_bearer(&headers, &reduced) {\n- Err((e, h)) => line_err(e, h),\n- Ok(viewer) => {\n+ let reduced = state.reduced.read().await;\n+ match verify_bearer_principal(&headers, &reduced) {\n+ Err((_, m)) => line_err(m, None),\n+ Ok(viewer) => {\n+ let delegate_parsed: Option> = delegate\n+ .as_deref()\n+ .map(str::trim)\n+ .filter(|s| !s.is_empty())\n+ .map(parse_agent);\n+ match delegate_parsed {\n+ Some(Err(msg)) => {\n+ drop(reduced);\n+ line_err(\"invalid delegate\", Some(msg))\n+ }\n+ Some(Ok(delegate_stored)) => {\n+ let line = if reduced.agent_bindings.get(&delegate_stored) != Some(&viewer) {\n+ line_err(\n+ \"not your delegate\",\n+ Some(\"this delegate is not bound to your signed-in account\".into()),\n+ )\n+ } else {\n+ let since_default = reduced\n+ .ingests_ordered\n+ .iter()\n+ .rev()\n+ .filter_map(|id| reduced.ingests_by_id.get(id))\n+ .find(|ing| {\n+ if ing.delegate.as_deref() != Some(delegate_stored.as_str()) {\n+ return false;\n+ }\n+ let scope = scope_from_room_wire(&ing.room_id);\n+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))\n+ })\n+ .map(|ing| ing.ts);\n+ let since = since.or(since_default);\n+ let cutoff = since.unwrap_or(0);\n+ let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);\n+ let matching: Vec<&str> = reduced.ingests_ordered.iter().rev()\n+ .map(|id| id.as_str())\n+ .take_while(|id| reduced.ingests_by_id.get(*id).map_or(false, |ing| ing.ts > cutoff))\n+ .filter(|id| {\n+ reduced.ingests_by_id.get(*id).is_some_and(|ing| {\n+ let scope = scope_from_room_wire(&ing.room_id);\n+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))\n+ })\n+ })\n+ .collect();\n+ let total = matching.len();\n+ let posts: Vec = matching.into_iter()\n+ .take(limit)\n+ .filter_map(|id| reduced.ingests_by_id.get(id))\n+ .filter(|ing| !reduced.redacted_posts.contains(&ing.id))\n+ .map(|ing| {\n+ let scope = scope_from_room_wire(&ing.room_id);\n+ let thread_post_index = reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope, ing.thread_tag.clone()))\n+ .and_then(|q| {\n+ q.iter().rev().position(|pid| pid == &ing.id).map(|i| i + 1)\n+ });\n+ FeedPost {\n+ ts: ing.ts,\n+ id: ing.id.clone(),\n+ thread: Some(ing.thread_tag.clone()),\n+ thread_post_index,\n+ body: ing.raw.clone(),\n+ }\n+ })\n+ .collect();\n+ line_ok(RpcResult::Feed(FeedResponse {\n+ delegate: Some(delegate_stored),\n+ since,\n+ posts,\n+ total,\n+ }))\n+ };\n+ drop(reduced);\n+ line\n+ }\n+ None => {\n+ // Session catch-up: last time *you* posted anything (delegate or not), so revisiting\n+ // an old chat with only a token still gets a sane cutoff.\n let since_default = reduced\n .ingests_ordered\n .iter()\n .rev()\n .filter_map(|id| reduced.ingests_by_id.get(id))\n .find(|ing| {\n- if ing.principal != actor_stored {\n+ if ing.principal != viewer {\n return false;\n }\n let scope = scope_from_room_wire(&ing.room_id);\n- can_view_scope(&reduced, &scope, viewer.as_deref())\n+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))\n })\n .map(|ing| ing.ts);\n let since = since.or(since_default);\n@@ -1820,7 +1895,7 @@ pub async fn handle_rpc_batch(\n .filter(|id| {\n reduced.ingests_by_id.get(*id).is_some_and(|ing| {\n let scope = scope_from_room_wire(&ing.room_id);\n- can_view_scope(&reduced, &scope, viewer.as_deref())\n+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))\n })\n })\n .collect();\n@@ -1846,12 +1921,14 @@ pub async fn handle_rpc_batch(\n }\n })\n .collect();\n- line_ok(RpcResult::Feed(FeedResponse {\n- actor: actor_stored,\n+ let line = line_ok(RpcResult::Feed(FeedResponse {\n+ delegate: None,\n since,\n posts,\n total,\n- }))\n+ }));\n+ drop(reduced);\n+ line\n }\n }\n }\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex 831722430d6b41308e3f03d485cd62f887ace799..124b7ca000384d5ad4c3062630050dbe587abffe 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -324,13 +324,13 @@ async fn test_private_room_read_requires_explicit_view_capability() {\n \"search should hide private posts without view capability\"\n );\n \n- // Feed should likewise hide private posts.\n+ // Feed should likewise hide private posts (keyed by delegate on those ingests).\n let feed = rpc_batch(\n &client,\n addr,\n Some(&bearer),\n serde_json::json!([{\n- \"GetFeed\": { \"actor\": \"testuser\", \"limit\": 20 }\n+ \"GetFeed\": { \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\", \"limit\": 20 }\n }]),\n )\n .await;\n@@ -343,6 +343,179 @@ async fn test_private_room_read_requires_explicit_view_capability() {\n );\n }\n \n+#[tokio::test]\n+async fn test_feed_since_last_post_is_scoped_to_delegate() {\n+ let (addr, _tmp, _log, _handle) = create_test_server().await;\n+ let client = reqwest::Client::new();\n+ let bearer = test_bearer();\n+\n+ let d1 = \"00000000-0000-0000-0000-0000000000a1:feedtest:local/model-a\";\n+ let d2 = \"00000000-0000-0000-0000-0000000000a2:feedtest:local/model-b\";\n+\n+ let p1 = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"feed-delegate-test\",\n+ \"delegate\": d1,\n+ \"text\": \"#feed-delegate-test\\nalpha marker for d1\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]),\n+ )\n+ .await;\n+ assert_eq!(p1[\"results\"][0][\"ok\"], true, \"first post: {:?}\", p1);\n+\n+ let p2 = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"feed-delegate-test\",\n+ \"delegate\": d2,\n+ \"text\": \"#feed-delegate-test\\nbeta marker for d2\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]),\n+ )\n+ .await;\n+ assert_eq!(p2[\"results\"][0][\"ok\"], true, \"second post: {:?}\", p2);\n+\n+ // Since d1 last posted first, the feed for d1 should include everything after that (here: d2's post).\n+ let feed = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"GetFeed\": { \"delegate\": d1, \"limit\": 20 }\n+ }]),\n+ )\n+ .await;\n+ let line = &feed[\"results\"][0];\n+ assert_eq!(line[\"ok\"], true, \"GetFeed failed: {:?}\", line);\n+ let delegate = line[\"result\"][\"Feed\"][\"delegate\"].as_str().unwrap();\n+ assert_eq!(delegate, d1);\n+ let bodies: String = line[\"result\"][\"Feed\"][\"posts\"]\n+ .as_array()\n+ .unwrap()\n+ .iter()\n+ .filter_map(|p| p[\"body\"].as_str())\n+ .collect::>()\n+ .join(\"\\n\");\n+ assert!(\n+ bodies.contains(\"beta marker for d2\"),\n+ \"expected d2's post after d1's cutoff, got: {bodies}\"\n+ );\n+ assert!(\n+ !bodies.contains(\"alpha marker for d1\"),\n+ \"d1's own prior post should not appear after cutoff, got: {bodies}\"\n+ );\n+\n+ let d_other = \"00000000-0000-0000-0000-0000000000ff:feedtest:local/other\";\n+ let steal = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"GetFeed\": { \"delegate\": d_other, \"limit\": 5 }\n+ }]),\n+ )\n+ .await;\n+ let steal_line = &steal[\"results\"][0];\n+ assert_eq!(steal_line[\"ok\"], false, \"expected rejection for unbound delegate: {:?}\", steal_line);\n+ assert_eq!(steal_line[\"error\"], \"not your delegate\");\n+\n+ let no_auth = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetFeed\": { \"delegate\": d1, \"limit\": 5 }\n+ }]),\n+ )\n+ .await;\n+ let na = &no_auth[\"results\"][0];\n+ assert_eq!(na[\"ok\"], false, \"GetFeed without bearer: {:?}\", na);\n+ assert_eq!(na[\"error\"], \"missing Authorization header\");\n+}\n+\n+#[tokio::test]\n+async fn test_feed_without_delegate_uses_principal_last_post_including_delegate() {\n+ let (addr, _tmp, _log, _handle) = create_test_server().await;\n+ let client = reqwest::Client::new();\n+ let bearer = test_bearer();\n+\n+ let d = \"00000000-0000-0000-0000-0000000000b1:principalfeed:local/model\";\n+ let p1 = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"principal-feed-test\",\n+ \"delegate\": d,\n+ \"text\": \"#principal-feed-test\\nfirst delegate post\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]),\n+ )\n+ .await;\n+ assert_eq!(p1[\"results\"][0][\"ok\"], true, \"{:?}\", p1);\n+\n+ let p2 = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"principal-feed-test\",\n+ \"delegate\": d,\n+ \"text\": \"#principal-feed-test\\nsecond delegate post\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]),\n+ )\n+ .await;\n+ assert_eq!(p2[\"results\"][0][\"ok\"], true, \"{:?}\", p2);\n+\n+ // Argless GetFeed: cutoff is last ingest by this principal (even if every post used --delegate),\n+ // so revisiting an old chat with only a token still gets a bounded \"since\", not full history.\n+ let feed = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"GetFeed\": { \"limit\": 20 }\n+ }]),\n+ )\n+ .await;\n+ let line = &feed[\"results\"][0];\n+ assert_eq!(line[\"ok\"], true, \"{:?}\", line);\n+ assert!(\n+ line[\"result\"][\"Feed\"][\"delegate\"].is_null(),\n+ \"principal-wide feed omits delegate in JSON: {:?}\",\n+ line[\"result\"][\"Feed\"]\n+ );\n+ assert!(\n+ line[\"result\"][\"Feed\"][\"since\"].is_number(),\n+ \"expected since from principal's last post (including delegate ingests), got: {:?}\",\n+ line[\"result\"][\"Feed\"][\"since\"]\n+ );\n+ let posts = line[\"result\"][\"Feed\"][\"posts\"].as_array().unwrap();\n+ assert!(\n+ posts.is_empty(),\n+ \"nothing is strictly newer than the latest own post; got {} posts\",\n+ posts.len()\n+ );\n+}\n+\n #[tokio::test]\n async fn test_private_room_thread_urls_use_t_segment() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\ndiff --git a/types/src/lib.rs b/types/src/lib.rs\nindex 5fc867bf2af84c2ca63fa1cdd03413110ad784a3..ff332ca8a752db3db13c59c94510f09848978134 100644\n--- a/types/src/lib.rs\n+++ b/types/src/lib.rs\n@@ -228,9 +228,10 @@ pub struct VoteRow {\n /// Response for the feed endpoint — all ingests since a cutoff, newest first.\n #[derive(Debug, Serialize, Deserialize)]\n pub struct FeedResponse {\n- /// Principal username this feed is scoped to (stored form, no `@`).\n- pub actor: String,\n- /// The lower-bound timestamp used (actor's last ingest, ms). None if actor has never posted.\n+ /// When set, feed cutoff is scoped to this agent delegate (`uuid:rig:provider/model`). Omitted when using principal-wide catch-up (`GetFeed` without `delegate`).\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub delegate: Option,\n+ /// Lower-bound timestamp (ms): last matching ingest for the chosen scope. None if no prior matching ingest.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub since: Option,\n pub posts: Vec,\n@@ -401,7 +402,9 @@ pub enum RpcCommand {\n query: String,\n },\n GetFeed {\n- actor: String,\n+ /// When omitted or empty, feed uses the bearer principal's last ingest as the cutoff (any delegate or none).\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ delegate: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n since: Option,\n #[serde(default)]\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[963c2670] tiny\n\nSide B — unified diff (full patch):\ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\nindex 368b1016eb43d83e9ff39464ff6164fa9a8b558a..f1114562c8c0196f78a008028b46d1fda57479f9 100644\n--- a/server/src/html/forum.rs\n+++ b/server/src/html/forum.rs\n@@ -618,7 +618,6 @@ pub async fn home(\n }\n }\n }\n- h2 { \"public threads\" }\n p class=\"muted\" { \"dark = time-ordered · light = vote-ranked\" }\n (render_thread_feed(Some(&nav), \"thread-feed\", &public_rows, now))\n (new_thread_form_public(show_forms))\n@@ -658,7 +657,7 @@ fn render_thread_paginator(nav: &ThreadNav, tag: &str, offset: usize, total: usi\n @if let Some(o) = older_offset {\n a href=(nav.thread_page_url(tag, o)) class=\"post-nav-btn\" { \"← older\" }\n } @else {\n- span class=\"post-nav-btn disabled\" { \"← older\" }\n+ a href=\"#\" class=\"post-nav-btn disabled\" { \"← older\" }\n }\n span class=\"post-nav-pos muted\" {\n (offset + 1) \"–\" (total.min(offset + PAGE_SIZE)) \" / \" (total)\n","role":"user"}],"model":"~x-ai/grok-latest"}