{"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[39d32611] helpers and cleanups\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 5049a26b096bd8435d6eb9e75ccb751b6f489061..0ff2d701dd1e8661f58abd55672cd91280e9491e 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -1333,11 +1333,8 @@ pub async fn handle_rpc_batch(\n }).collect()\n })\n .unwrap_or_default();\n- let thread_post_index = reduced\n- .ingests_by_scope_thread\n- .get(&(scope.clone(), e.thread.clone()))\n- .and_then(|q| q.iter().rev().position(|id| id == &e.post_id))\n- .expect(\"rank history post_id must be in ingests_by_scope_thread for (scope, thread)\");\n+ let thread_post_index =\n+ reduced.thread_post_index_chronological(&scope, &e.thread, &e.post_id);\n RankHistoryRow {\n ts: e.ts,\n scope_rank: e.scope_rank,\n@@ -1504,11 +1501,11 @@ pub async fn handle_rpc_batch(\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+ .try_thread_post_index_chronological(\n+ &scope,\n+ &ing.thread_tag,\n+ &ing.id,\n+ );\n FeedPost {\n ts: ing.ts,\n id: ing.id.clone(),\n@@ -1565,11 +1562,11 @@ pub async fn handle_rpc_batch(\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+ .try_thread_post_index_chronological(\n+ &scope,\n+ &ing.thread_tag,\n+ &ing.id,\n+ );\n FeedPost {\n ts: ing.ts,\n id: ing.id.clone(),\ndiff --git a/server/src/html/forum/ingest.rs b/server/src/html/forum/ingest.rs\nindex 308358abc6cd6811079d9ec41b114093c0294d02..27d1852cf3b2a1718605e4633d4238383adfe33d 100644\n--- a/server/src/html/forum/ingest.rs\n+++ b/server/src/html/forum/ingest.rs\n@@ -1,4 +1,3 @@\n-use crate::canonical_path::canonicalize_tag;\n use crate::form_template::template_json_compact;\n use crate::reducer::{scope_from_room_wire, ReducerState};\n use maud::{html, Markup};\n@@ -31,11 +30,7 @@ pub(super) fn thread_nav_for_ingest(ing: &crate::events::Ingest) -> Option Option {\n let scope = scope_from_room_wire(&ing.room_id);\n- let tag = canonicalize_tag(&ing.thread_tag);\n- reduced\n- .ingests_by_scope_thread\n- .get(&(scope, tag))\n- .and_then(|q| q.iter().rev().position(|id| id == &ing.id))\n+ reduced.try_thread_post_index_chronological(&scope, &ing.thread_tag, &ing.id)\n }\n \n fn post_header_meta(\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex 23245d6fdfc9019b199ab8417150faf5f3297067..423f23fd8c9ad7b7f454d6ea7a9a7607a4c9c5b9 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -11,7 +11,7 @@ use crate::{\n canonical_path::canonicalize_item,\n events::ThreadCapability,\n path_types::CanonicalItemUrl,\n- reducer::{ReducerState, ScopeId},\n+ reducer::{ContentState, ReducerState, ScopeId},\n ranking::{connected_components_from_voted_pairs, ranked_items_subset},\n scope_rank::{build_children_rankings, ChildrenRankings},\n state::AppState,\n@@ -133,6 +133,23 @@ fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&s\n reduced.user_has_cap(room_id, u, ThreadCapability::View)\n }\n \n+/// Private `~/` / `-/` garden pages require at least one ingest in that scope (a `content` entry).\n+fn room_scope_has_garden_content(reduced: &ReducerState, nav: &ThreadNav) -> bool {\n+ match nav.scope() {\n+ ScopeId::Public => true,\n+ ScopeId::Room(_) => reduced.content_for_scope(&nav.scope()).is_some(),\n+ }\n+}\n+\n+fn content_for_garden_view<'a>(reduced: &'a ReducerState, scope: &ScopeId) -> &'a ContentState {\n+ match scope {\n+ ScopeId::Public => reduced.public(),\n+ ScopeId::Room(_) => reduced.content_for_scope(scope).expect(\n+ \"room garden only renders after room_scope_has_garden_content returned true\",\n+ ),\n+ }\n+}\n+\n /// Ontology index — root-level paths. Private (UUID) roots are excluded.\n pub async fn garden_index(\n State(state): State,\n@@ -298,16 +315,20 @@ pub async fn room_garden_index(\n uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n return room_not_found_page(&jar, &uri).into_response();\n }\n+ if !room_scope_has_garden_content(&reduced, &nav) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n drop(reduced);\n- let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n- return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n- };\n render_scope_view(\n state,\n GardenBrowsePath::Tilde(OntologyPath::root()),\n@@ -326,25 +347,26 @@ pub async fn room_external_garden_index(\n uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n return room_not_found_page(&jar, &uri).into_response();\n }\n- drop(reduced);\n- let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n- return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n- };\n+ if !room_scope_has_garden_content(&reduced, &nav) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n let ext_path = ExternalOntologyPath::from_input(\"\");\n let parent = CanonicalItemUrl::parse(\"https://.\").unwrap();\n- let child_rankings = {\n- let reduced = state.reduced.read().await;\n- let content = reduced\n- .content_for_scope(&nav.scope())\n- .unwrap_or_else(|| reduced.public());\n- build_children_rankings(content, &parent)\n- };\n+ let child_rankings = build_children_rankings(\n+ content_for_garden_view(&reduced, &nav.scope()),\n+ &parent,\n+ );\n+ drop(reduced);\n \n let page = layout(\n \"-/\",\n@@ -400,16 +422,20 @@ pub async fn room_external_ontology_path(\n uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n return room_not_found_page(&jar, &uri).into_response();\n }\n+ if !room_scope_has_garden_content(&reduced, &nav) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n drop(reduced);\n- let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n- return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n- };\n let path = ExternalOntologyPath::from_input(&path);\n render_scope_view(state, GardenBrowsePath::External(path), nav, jar, uri).await\n }\n@@ -422,16 +448,20 @@ pub async fn room_ontology_path(\n uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n return room_not_found_page(&jar, &uri).into_response();\n }\n+ if !room_scope_has_garden_content(&reduced, &nav) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n drop(reduced);\n- let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n- return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n- };\n let path = OntologyPath::from_input(&path);\n render_scope_view(state, GardenBrowsePath::Tilde(path), nav, jar, uri).await\n }\n@@ -474,9 +504,7 @@ fn build_sibling_rank(\n item: &CanonicalItemUrl,\n ) -> Option {\n let item = item.clone().normalized_storage();\n- let content = reduced\n- .content_for_scope(scope)\n- .unwrap_or_else(|| reduced.public());\n+ let content = content_for_garden_view(reduced, scope);\n let group = &content.ranking_group;\n let parent = item.parent()?.normalized_storage();\n let siblings: Vec = content\n@@ -537,9 +565,7 @@ fn build_rank_history(\n scope: &ScopeId,\n item: &str,\n ) -> Vec {\n- let content = reduced\n- .content_for_scope(scope)\n- .unwrap_or_else(|| reduced.public());\n+ let content = content_for_garden_view(reduced, scope);\n let item_key = CanonicalItemUrl(item.to_string());\n let entries = match content.rank_history.get(&item_key) {\n None => return vec![],\n@@ -574,11 +600,8 @@ fn build_rank_history(\n })\n .unwrap_or_default();\n \n- let thread_post_index = reduced\n- .ingests_by_scope_thread\n- .get(&(scope.clone(), e.thread.clone()))\n- .and_then(|q| q.iter().rev().position(|id| id == &e.post_id))\n- .expect(\"rank history post_id must be in ingests_by_scope_thread for (scope, thread)\");\n+ let thread_post_index =\n+ reduced.thread_post_index_chronological(scope, &e.thread, &e.post_id);\n \n RankHistoryEntryView {\n ts: e.ts,\n@@ -597,9 +620,7 @@ fn build_item_page_view_model(\n scope: &ScopeId,\n item: &str,\n ) -> ItemPageViewModel {\n- let content = reduced\n- .content_for_scope(scope)\n- .unwrap_or_else(|| reduced.public());\n+ let content = content_for_garden_view(reduced, scope);\n let item_key = CanonicalItemUrl::parse(item)\n .unwrap_or_else(|| CanonicalItemUrl::parse(\"~/\").unwrap())\n .normalized_storage();\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 37d17e0810b627b0b54884ce707c372b69708df2..a8651994f2c062af25b6ad792a014dcc083296f9 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -265,6 +265,31 @@ impl ReducerState {\n self.content.get(scope)\n }\n \n+ /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`).\n+ pub fn try_thread_post_index_chronological(\n+ &self,\n+ scope: &ScopeId,\n+ thread_tag: &str,\n+ post_id: &str,\n+ ) -> Option {\n+ let tag = canonicalize_tag(thread_tag);\n+ self.ingests_by_scope_thread\n+ .get(&(scope.clone(), tag))\n+ .and_then(|q| q.iter().rev().position(|pid| pid == post_id))\n+ }\n+\n+ pub fn thread_post_index_chronological(\n+ &self,\n+ scope: &ScopeId,\n+ thread_tag: &str,\n+ post_id: &str,\n+ ) -> usize {\n+ self.try_thread_post_index_chronological(scope, thread_tag, post_id)\n+ .expect(\n+ \"post_id must appear in ingests_by_scope_thread for this scope and thread\",\n+ )\n+ }\n+\n /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest.\n pub fn posts_by_actor_ids(&self, actor: &str) -> Vec {\n self.posts_by_actor\n@@ -598,6 +623,8 @@ impl ReducerState {\n let _ = Self::apply_ingest_to_content(&mut cs, ing);\n }\n self.content.insert(scope, cs);\n+ // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so\n+ // per-post URLs and chronological indices remain stable; only projected garden state resets.\n }\n \n /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants).\n@@ -720,6 +747,7 @@ impl ReducerState {\n return;\n };\n let scope = scope_from_room_wire(&ing.room_id.trim());\n+ // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`.\n self.rebuild_scope_content(scope);\n }\n Event::GrantAdded(ga) => {\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex 9766adb43ad315a64f5df17b79f66718ce149509..515c691d04ef3f1f4bdfa8ee911360a43fd2b26e 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -760,6 +760,43 @@ async fn test_private_room_garden_root_lists_top_level_tilde_children() {\n assert!(body.contains(\"ordering 1\"));\n }\n \n+#[tokio::test]\n+async fn test_empty_private_room_garden_returns_404() {\n+ let (addr, _tmp, _log, _handle) = create_test_server().await;\n+ let client = reqwest::Client::builder()\n+ .redirect(reqwest::redirect::Policy::none())\n+ .build()\n+ .unwrap();\n+ let bearer = test_bearer();\n+\n+ let create = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"RoomCreate\": { \"slug\": \"empty-garden-room\" }\n+ }]),\n+ )\n+ .await;\n+ let room_id = create[\"results\"][0][\"result\"][\"RoomCreated\"][\"room_id\"]\n+ .as_str()\n+ .unwrap()\n+ .to_string();\n+ let (room_short, room_slug) = room_id.split_once('/').unwrap();\n+\n+ let root = client\n+ .get(format!(\"http://{addr}/r/{room_short}/{room_slug}/~\"))\n+ .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert_eq!(\n+ root.status(),\n+ reqwest::StatusCode::NOT_FOUND,\n+ \"no ingest yet → no room scope content → must not fall back to public garden\"\n+ );\n+}\n+\n #[tokio::test]\n async fn test_post_check_returns_targeted_js_error_for_missing_thread_tag() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\ndiff --git a/test/browser_redact_thread_index.clj b/test/browser_redact_thread_index.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..88a86fb9844fcf78df85e59f21f2e37f27df96a5\n--- /dev/null\n+++ b/test/browser_redact_thread_index.clj\n@@ -0,0 +1,169 @@\n+(ns test.browser-redact-thread-index\n+ \"Regression: after PostRedacted, rebuild_scope_content drops garden projection for the redacted\n+ ingest but ingests_by_scope_thread keeps tombstone ids so chronological indices stay aligned\n+ with /t/:tag/:index and GetRankHistory.thread_post_index. If redacted ids were removed from the\n+ deque without replumbing rank history / single-post resolution, the second post would incorrectly\n+ get thread_post_index 0 (and GetRankHistory would panic if history rows still referenced the\n+ redacted post_id).\"\n+ (:require [babashka.fs :as fs]\n+ [cheshire.core :as json]\n+ [clojure.string :as str]\n+ [clojure.test :refer [deftest is]]\n+ [com.blockether.spel.core :as core]\n+ [com.blockether.spel.locator :as locator]\n+ [com.blockether.spel.page :as page]\n+ [test.common :as common]\n+ [test.oauth :as oauth]))\n+\n+(defn- wait-for-text [pg selector expected timeout-ms]\n+ (let [deadline (+ (System/currentTimeMillis) timeout-ms)]\n+ (loop []\n+ (let [text (locator/text-content (page/locator pg selector))]\n+ (if (and (string? text) (str/includes? text expected))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false))))))\n+\n+(defn- thread-post-at-index [forum-json idx]\n+ (some (fn [item]\n+ (when (and (= \"post\" (get item \"kind\"))\n+ (= idx (get item \"index\")))\n+ item))\n+ (get-in forum-json [\"results\" 0 \"result\" \"ForumThread\" \"items\"])))\n+\n+(defn redact-preserves-thread-chrono-index-flow! []\n+ (println \"\\n━━━ browser: PostRedact vs thread_post_index_chronological ━━━\\n\")\n+\n+ (common/letlocals\n+ (bind build (common/run-cargo-build-release! [\"slugsocial-server\"]))\n+ (is (zero? (:exit build)) \"cargo build succeeds\")\n+ (bind server-bin \"target/release/slugsocial-server\")\n+\n+ (bind tmp-dir (str (fs/create-temp-dir {:prefix \"slug-browser-redact-idx-\"})))\n+ (bind slug-port (common/pick-port))\n+ (bind google-port (common/pick-port))\n+ (bind base-url (str \"http://127.0.0.1:\" slug-port))\n+ (bind google-url (str \"http://127.0.0.1:\" google-port))\n+\n+ (bind !server (atom nil))\n+ (bind !google (atom nil))\n+ (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port))\n+ (try\n+ (reset! !google (oauth/start-mock-google google-port\n+ :google-users [\"google-user-alice\"]))\n+ (reset! !server (common/start-server server-bin server-env))\n+ (is (common/wait-for-server base-url 10000) \"server responds to /healthz\")\n+\n+ (let [alice-token (oauth/fetch-bearer-token! base-url :username \"alice\")\n+ thread-tag \"redact-thread-idx\"\n+ item-second \"~/rix/c\"\n+ ;; Two chronologically ordered posts in the same thread with disjoint tilde paths so\n+ ;; replay after redacting the first only reapplies the second ingest successfully.\n+ text-first (str \"# \" thread-tag \"\\n\\n\"\n+ \"~/rix/a {alpha}\\n\"\n+ \"~/rix/b {beta}\\n\"\n+ \"~/rix/a 2:1 ~/rix/b {browser redact thread idx post one}\\n\")\n+ text-second (str \"# \" thread-tag \"\\n\\n\"\n+ \"~/rix/c {gamma}\\n\"\n+ \"~/rix/d {delta}\\n\"\n+ \"~/rix/c 2:1 ~/rix/d {browser redact thread idx post two}\\n\")\n+ _ (is (true? (get-in (json/parse-string\n+ (:body (oauth/http-post-json\n+ (str base-url \"/api/v0/rpc\")\n+ [{\"Post\" {\"room\" \"public\"\n+ \"thread_tag\" thread-tag\n+ \"text\" text-first\n+ \"return_rank_diff\" false}}]\n+ :headers {\"Authorization\" (str \"Bearer \" alice-token)}))\n+ false)\n+ [\"results\" 0 \"ok\"]))\n+ \"first Post rpc ok\")\n+ _ (is (true? (get-in (json/parse-string\n+ (:body (oauth/http-post-json\n+ (str base-url \"/api/v0/rpc\")\n+ [{\"Post\" {\"room\" \"public\"\n+ \"thread_tag\" thread-tag\n+ \"text\" text-second\n+ \"return_rank_diff\" false}}]\n+ :headers {\"Authorization\" (str \"Bearer \" alice-token)}))\n+ false)\n+ [\"results\" 0 \"ok\"]))\n+ \"second Post rpc ok\")\n+ thr (json/parse-string\n+ (:body (oauth/http-post-json\n+ (str base-url \"/api/v0/rpc\")\n+ [{\"GetForumThread\" {\"room\" \"public\"\n+ \"thread_tag\" thread-tag\n+ \"offset\" 0\n+ \"limit\" 50\n+ \"since\" nil\n+ \"before\" nil\n+ \"actor\" nil\n+ \"post_id\" nil}}]\n+ :headers {}))\n+ false)\n+ p0 (thread-post-at-index thr 0)\n+ p1 (thread-post-at-index thr 1)\n+ id-oldest (get p0 \"id\")\n+ id-newer (get p1 \"id\")\n+ _ (is (some? id-oldest) \"thread has chronological index 0\")\n+ _ (is (some? id-newer) \"thread has chronological index 1\")\n+ _ (is (not= id-oldest id-newer) \"two distinct post ids\")\n+ rx (oauth/http-post-json\n+ (str base-url \"/api/v0/rpc\")\n+ [{\"PostRedact\" {\"post_id\" id-oldest}}]\n+ :headers {\"Authorization\" (str \"Bearer \" alice-token)})\n+ _ (is (true? (get-in (json/parse-string (:body rx) false) [\"results\" 0 \"ok\"]))\n+ \"PostRedact oldest succeeds\")\n+ hist-resp (oauth/http-post-json\n+ (str base-url \"/api/v0/rpc\")\n+ [{\"GetRankHistory\" {\"room\" \"public\"\n+ \"item_path\" item-second}}])\n+ hist-json (json/parse-string (:body hist-resp) false)\n+ history (get-in hist-json [\"results\" 0 \"result\" \"RankHistory\" \"history\"])]\n+ (is (true? (get-in hist-json [\"results\" 0 \"ok\"]))\n+ \"GetRankHistory ok after redact\")\n+ (is (seq history)\n+ \"GetRankHistory returns rows for second-post-only item (garden replayed without first ingest)\")\n+ (doseq [[i row] (map-indexed vector history)]\n+ (is (= 1 (get row \"thread_post_index\"))\n+ (str \"RankHistory row \" i \": thread_post_index must stay 1 for the newer post after \"\n+ \"redacting chronological index 0. Stable indices require keeping redacted ids in \"\n+ \"ingests_by_scope_thread; naively filtering them out makes this 0 (and can panic \"\n+ \"rank history if rows still reference the redacted post).\")))\n+ (let [url-0 (str base-url \"/t/\" thread-tag \"/0\")\n+ url-1 (str base-url \"/t/\" thread-tag \"/1\")\n+ g0 (oauth/http-get url-0)\n+ g1 (oauth/http-get url-1)]\n+ (is (= 200 (:status g0))\n+ \"GET /t/:tag/0 responds after redact (tombstone slot preserved)\")\n+ (is (str/includes? (:body g0) \"deleted\")\n+ \"single-post URL index 0 shows tombstone after redact\")\n+ (is (= 200 (:status g1))\n+ \"GET /t/:tag/1 responds\")\n+ (is (str/includes? (:body g1) \"browser redact thread idx post two\")\n+ \"index 1 still resolves to the second post body (not shifted to 0)\"))\n+ (core/with-playwright [pw]\n+ (core/with-browser [browser (core/launch-chromium pw {:headless true :channel \"chrome\"})]\n+ (core/with-context [ctx (core/new-context browser)]\n+ (core/with-page [pg (core/new-page-from-context ctx)]\n+ (page/navigate pg (str base-url \"/t/\" thread-tag \"/0\"))\n+ (is (wait-for-text pg \"body\" \"deleted\" 15000)\n+ \"browser: per-post view 0 shows deleted tombstone\")\n+ (page/navigate pg (str base-url \"/t/\" thread-tag \"/1\"))\n+ (is (wait-for-text pg \"body\" \"browser redact thread idx post two\" 15000)\n+ \"browser: per-post view 1 still shows second post\"))))))\n+\n+ (finally\n+ (when-some [s @!server] (common/kill-server s))\n+ (when-some [g @!google] ((:stop-fn g)))\n+ (fs/delete-tree tmp-dir)))\n+\n+ nil))\n+\n+(defn browser-redact-thread-index-test [& _args]\n+ (redact-preserves-thread-chrono-index-flow!))\n+\n+(deftest browser-redact-thread-index-stable-after-post-redacted\n+ (redact-preserves-thread-chrono-index-flow!))\ndiff --git a/tests.edn b/tests.edn\nindex 6e96432ba766461b4233c75dc48db2633483e5e0..bbc4545da34f9fad41e92a336500d79e4294f51c 100644\n--- a/tests.edn\n+++ b/tests.edn\n@@ -17,7 +17,8 @@\n \"^test\\\\.browser-ui-morph$\"\n \"^test\\\\.browser-post-redact$\"\n \"^test\\\\.browser-room-delete$\"\n- \"^test\\\\.browser-public-garden$\"]\n+ \"^test\\\\.browser-public-garden$\"\n+ \"^test\\\\.browser-redact-thread-index$\"]\n :kaocha.filter/skip-meta [:skip]\n :parallel? false}]\n :plugins [:kaocha.plugin/junit-xml]\ndiff --git a/types/src/lib.rs b/types/src/lib.rs\nindex 49abba8ea9f786ef68e3157d2a5d309e15e09ba0..fce1b5fa5e259a4b86a11cb4d4f7cc9bec3a0aa1 100644\n--- a/types/src/lib.rs\n+++ b/types/src/lib.rs\n@@ -253,7 +253,7 @@ pub struct FeedPost {\n /// Primary thread tag (without #), if the ingest declared one.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub thread: Option,\n- /// 1-based display ordinal for this post within the thread (feed only; URLs use 0-based paths).\n+ /// 0-based chronological index within the thread (same as forum `/t/tag/N`).\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub thread_post_index: Option,\n /// Full raw body of the ingest document.\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[39f8fb3c] nit\n\nSide B — unified diff (full patch):\ndiff --git a/server/src/html/forum/new_thread.rs b/server/src/html/forum/new_thread.rs\nindex 4dd2654f953a7db6ee9b25c348f99906da93a94a..3ef791ea96f83ee9bb333ec225483ac317c2881d 100644\n--- a/server/src/html/forum/new_thread.rs\n+++ b/server/src/html/forum/new_thread.rs\n@@ -15,7 +15,6 @@ const TAG_INPUT_ID: &str = \"new-thread-tag\";\n fn new_thread_compose_section(room_wire: &str) -> Markup {\n html! {\n section class=\"compose\" id=(COMPOSE_SECTION_ID) {\n- div id=(ERRORS_ID) {}\n form id=(FORM_ID) method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n \"action\": \"check_ingest\",\n \"room\": room_wire,\n@@ -36,6 +35,7 @@ fn new_thread_compose_section(room_wire: &str) -> Markup {\n textarea name=\"text\" rows=\"4\" placeholder=\"First post body…\" required {}\n p { button type=\"submit\" { \"create thread / post\" } }\n }\n+ div id=(ERRORS_ID) {}\n }\n }\n }\n","role":"user"}],"model":"~x-ai/grok-latest"}