Back to feed

sledtools/pika branch #34

pika-git-inbox-1

Fix forge inbox access and backfill

Target branch: master

Merge Commit: e01eda38363c7194967a31a7868569c34e6f16cd

branch: merged tutorial: ready ci: success
Open CI Details

Continuous Integration

CI: success

Compact status on the review page, with full logs on the CI page.

Open CI Details

Latest run #40 success

2 passed

head 5021d0b175266fe38a65e94ba8a27d00cda37143 · queued 2026-03-24 14:57:04 · 2 lane(s)

queued 8s · ran 3s

check-notifications · success check-agent-contracts · success

Summary

This branch fixes forge inbox access and backfill logic in pika-news. Previously, users with only can_forge_write permission (but not can_chat) were incorrectly denied access to the branch inbox and were not included in inbox recipient lists. The changes introduce a new require_inbox_auth function that grants inbox access based on forge mode context, update the backfill trigger logic to account for forge-write-only users, add a dedicated SQL migration to dismiss inbox items for closed branches, handle migration renumbering with a backwards-compatibility check for databases that already applied the old migration 0019, and fix the frontend auth gate to retain sessions for forge-write-only users.

Tutorial Steps

Add migration to dismiss inbox items for closed branches

Intent: Introduce a data-cleanup migration that transitions branch inbox items from 'inbox' to 'dismissed' state when the associated branch is no longer open, preventing stale inbox entries from accumulating.

Affected files: crates/pika-news/migrations/0019_dismiss_closed_branch_inbox.sql

Evidence
@@ -0,0 +1,11 @@
+UPDATE branch_inbox_states
+SET state = 'dismissed',
+    reason = 'branch_closed',
+    dismissed_at = COALESCE(dismissed_at, CURRENT_TIMESTAMP),
+    updated_at = CURRENT_TIMESTAMP
+WHERE state = 'inbox'
+  AND branch_id IN (
+      SELECT id
+      FROM branch_records
+      WHERE state != 'open'
+  );

A new migration 0019_dismiss_closed_branch_inbox.sql is added. It bulk-updates all branch_inbox_states rows that are still in the 'inbox' state but belong to branches whose state is no longer 'open'. These rows are transitioned to 'dismissed' with reason 'branch_closed', using COALESCE on dismissed_at to preserve any previously recorded timestamp.

This is a one-time data cleanup that ensures closed branches don't leave dangling inbox items for users.

Renumber the CI queue state migration from 0019 to 0020

Intent: Make room for the new dismiss-closed-branches migration at version 19 by bumping the existing CI queue state migration to version 20, and add a backwards-compatibility guard for databases that already applied it as version 19.

Affected files: crates/pika-news/migrations/0019_ci_queue_state_and_target_health.sql, crates/pika-news/src/storage.rs

Evidence
@@ rename from crates/pika-news/migrations/0019_ci_queue_state_and_target_health.sql
 rename to crates/pika-news/migrations/0020_ci_queue_state_and_target_health.sql
@@ -2496,8 +2576,13 @@ fn migrations() -> Vec<Migration> {
         Migration {
             version: 19,
-            name: "0019_ci_queue_state_and_target_health",
-            sql: include_str!("../migrations/0019_ci_queue_state_and_target_health.sql"),
+            name: "0019_dismiss_closed_branch_inbox",
+            sql: include_str!("../migrations/0019_dismiss_closed_branch_inbox.sql"),
+        },
+        Migration {
+            version: 20,
+            name: "0020_ci_queue_state_and_target_health",
+            sql: include_str!("../migrations/0020_ci_queue_state_and_target_health.sql"),
@@ -2371,6 +2394,17 @@ fn run_migrations(conn: &mut Connection) -> anyhow::Result<()> {
+        if migration.name == "0020_ci_queue_state_and_target_health"
+            && ci_queue_state_schema_present(conn)?
+        {
+            conn.execute(
+                "INSERT INTO _pika_news_migrations(version, name) VALUES (?1, ?2)",
+                params![migration.version, migration.name],
+            )
+            .with_context(|| format!("record pre-applied migration {}", migration.name))?;
+            continue;
+        }

The file 0019_ci_queue_state_and_target_health.sql is renamed to 0020_ci_queue_state_and_target_health.sql, and the migrations() function in storage.rs is updated to reflect the new numbering: version 19 is now the dismiss-closed-branches migration, and version 20 is the CI queue state migration.

Since existing deployments may have already applied the CI queue state migration as version 19, a compatibility guard is added in run_migrations. Before applying migration 20, the runner calls ci_queue_state_schema_present(conn) to check if the schema changes (columns execution_reason and failure_kind on both branch_ci_run_lanes and nightly_run_lanes, plus the ci_target_health table) are already present. If so, it records the migration as applied without re-executing the SQL.

Three helper functions support this check:

  • ci_queue_state_schema_present — orchestrates the column/table checks
  • table_has_column — uses PRAGMA table_info to look for a specific column
  • table_exists — queries sqlite_master for a table name

Add list_branch_inbox_allowlist_npubs to include forge writers

Intent: Create a new storage method that returns all npubs eligible for the branch inbox, including users who only have `can_forge_write` permission (not just `active` chat users).

Affected files: crates/pika-news/src/storage.rs

Evidence
@@ -1421,6 +1421,29 @@ impl Store {
+    pub fn list_branch_inbox_allowlist_npubs(&self) -> anyhow::Result<Vec<String>> {
+        self.with_connection(|conn| {
+            let mut stmt = conn
+                .prepare(
+                    "SELECT npub
+                     FROM chat_allowlist
+                     WHERE active = 1 OR can_forge_write = 1
+                     ORDER BY npub ASC",
+                )
+                .context("prepare branch inbox allowlist query")?;
+
+            let rows = stmt
+                .query_map([], |row| row.get(0))
+                .context("query branch inbox allowlist")?;
+
+            let mut npubs = Vec::new();
+            for row in rows {
+                npubs.push(row.context("read branch inbox allowlist row")?);
+            }
+            Ok(npubs)
+        })
+    }

A new method list_branch_inbox_allowlist_npubs is added to Store. Unlike list_active_chat_allowlist_npubs (which filters on active = 1 only), this query uses WHERE active = 1 OR can_forge_write = 1, returning all users who should receive branch inbox items regardless of whether they have full chat access.

Existing tests for list_active_chat_allowlist_npubs are extended with assertions against the new method to verify that forge-write-only users appear in the branch inbox allowlist but not in the active chat list.

Use the new allowlist query in the worker's recipient list

Intent: Switch the branch inbox population logic to use the broader allowlist that includes forge-write-only users.

Affected files: crates/pika-news/src/worker.rs

Evidence
@@ -191,7 +191,7 @@ fn branch_inbox_recipients(store: &Store, config: &Config) -> anyhow::Result<Vec
-    for npub in store.list_active_chat_allowlist_npubs()? {
+    for npub in store.list_branch_inbox_allowlist_npubs()? {

In branch_inbox_recipients, the call to store.list_active_chat_allowlist_npubs() is replaced with store.list_branch_inbox_allowlist_npubs(). This single-line change ensures that when the worker populates inbox items for a new branch artifact, forge-write-only users are included as recipients.

The accompanying test is updated to add a forge_writer_npub to the allowlist with active=false, can_forge_write=true, and the expected recipient count increases from 3 to 4 to account for the new recipient.

Introduce require_inbox_auth for forge-mode-aware access control

Intent: Replace the use of `require_chat_auth` in inbox API handlers with a new `require_inbox_auth` function that grants access to forge-write-only users when the server is running in forge mode.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -4081,6 +4081,30 @@ fn require_chat_auth(
+fn require_inbox_auth(
+    auth: &AuthState,
+    headers: &axum::http::HeaderMap,
+    forge_mode: bool,
+) -> Result<String, axum::response::Response> {
+    let npub = require_auth(auth, headers)?;
+    let access = auth.access_for_npub(&npub);
+    let allowed = if forge_mode {
+        access.can_chat || access.can_forge_write
+    } else {
+        access.can_chat
+    };
+    if allowed {
+        Ok(npub)
+    } else {
+        Err((
+            StatusCode::FORBIDDEN,
+            Json(serde_json::json!({"error": "inbox access revoked"})),
+        )
+            .into_response())
+    }
+}
@@ -4404,14 +4449,14 @@ async fn api_inbox_list_handler(
-    let npub = match require_chat_auth(&state.auth, &headers) {
+    let forge_mode = state.config.effective_forge_repo().is_some();
+    let npub = match require_inbox_auth(&state.auth, &headers, forge_mode) {
@@ -4447,12 +4492,12 @@ async fn api_inbox_count_handler(
-    let npub = match require_chat_auth(&state.auth, &headers) {
+    let forge_mode = state.config.effective_forge_repo().is_some();
+    let npub = match require_inbox_auth(&state.auth, &headers, forge_mode) {
@@ -4488,12 +4533,12 @@ async fn api_inbox_dismiss_handler(
-    let npub = match require_chat_auth(&state.auth, &headers) {
+    let forge_mode = state.config.effective_forge_repo().is_some();
+    let npub = match require_inbox_auth(&state.auth, &headers, forge_mode) {
@@ -4534,12 +4579,12 @@ async fn api_inbox_neighbors_handler(
-    let npub = match require_chat_auth(&state.auth, &headers) {
+    let forge_mode = state.config.effective_forge_repo().is_some();
+    let npub = match require_inbox_auth(&state.auth, &headers, forge_mode) {

A new function require_inbox_auth is introduced. It takes a forge_mode boolean parameter and checks:

  • In forge mode: access is granted if the user has can_chat or can_forge_write
  • In non-forge mode: access requires can_chat (same as before)

All four inbox API handlers (api_inbox_list_handler, api_inbox_count_handler, api_inbox_dismiss_handler, api_inbox_neighbors_handler) are updated to:

  1. Compute forge_mode before the auth check (moved up from below)
  2. Call require_inbox_auth instead of require_chat_auth

This fixes the core bug: forge-write-only users were receiving inbox items (via the worker) but couldn't access them through the API because require_chat_auth only checked can_chat.

Fix backfill trigger logic for forge-write-only users

Intent: Update should_backfill_managed_allowlist_entry to correctly trigger backfill when a user gains forge-write access, not just when they gain active chat access.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -4390,8 +4419,24 @@ async fn api_admin_allowlist_upsert_handler(
 fn should_backfill_managed_allowlist_entry(
     existing: Option<&ChatAllowlistEntry>,
     active: bool,
+    can_forge_write: bool,
+    forge_mode: bool,
 ) -> bool {
-    active && existing.map(|entry| !entry.active).unwrap_or(true)
+    let was_reviewable = existing
+        .map(|entry| {
+            if forge_mode {
+                entry.active || entry.can_forge_write
+            } else {
+                entry.active
+            }
+        })
+        .unwrap_or(false);
+    let is_reviewable = if forge_mode {
+        active || can_forge_write
+    } else {
+        active
+    };
+    is_reviewable && !was_reviewable
+}
@@ -4356,7 +4380,12 @@ async fn api_admin_allowlist_upsert_handler(
-        let backfilled = if should_backfill_managed_allowlist_entry(existing.as_ref(), active) {
+        let backfilled = if should_backfill_managed_allowlist_entry(
+            existing.as_ref(),
+            active,
+            can_forge_write,
+            forge_mode,
+        ) {

The should_backfill_managed_allowlist_entry function previously only checked whether a user was becoming active for the first time. Now it takes two additional parameters: can_forge_write and forge_mode.

The logic now computes two booleans:

  • was_reviewable: whether the user previously had inbox review access (in forge mode: active || can_forge_write; otherwise: active)
  • is_reviewable: whether the user now has inbox review access (same logic with the new values)

Backfill triggers only when is_reviewable && !was_reviewable — i.e., the user is gaining review access they didn't have before. This prevents duplicate backfills when updating an already-active forge writer, and correctly triggers backfill when a user is granted forge-write access for the first time.

The caller in api_admin_allowlist_upsert_handler is updated to pass the additional arguments.

Fix frontend auth gate for forge-write-only sessions

Intent: Prevent the frontend from clearing authentication for users who have can_forge_write but not can_chat.

Affected files: crates/pika-news/templates/base.html

Evidence
@@ -203,7 +203,7 @@
-              if (!data.can_chat) {
+              if (!(data.can_chat || data.can_forge_write)) {

In base.html, the client-side auth validation previously called clearAuth() if data.can_chat was falsy. This meant forge-write-only users would have their session cleared on every page load, even though they have legitimate access.

The fix changes the condition to !(data.can_chat || data.can_forge_write), so sessions are only cleared when the user has neither permission.

Add comprehensive tests for the new behavior

Intent: Validate migration compatibility, forge-write-only inbox access, and updated backfill logic through unit and integration tests.

Affected files: crates/pika-news/src/storage.rs, crates/pika-news/src/web.rs, crates/pika-news/src/worker.rs

Evidence
@@ -2676,6 +2764,69 @@ mod tests {
+    #[test]
+    fn queue_state_migration_applies_after_legacy_version_19() {
@@ -5023,9 +5068,13 @@ mod tests {
-    fn managed_allowlist_backfills_only_for_new_active_entries() {
+    fn managed_allowlist_backfills_only_for_new_reviewable_entries() {
@@ -5037,23 +5086,100 @@ mod tests {
+    #[tokio::test]
+    async fn forge_only_writer_can_list_branch_inbox() {
@@ -306,10 +319,11 @@ mod tests {
-            3
+            4

Several tests are added or updated across the three source files:

storage.rs:

  • queue_state_migration_applies_after_legacy_version_19: Simulates a database that has applied all migrations through version 19 (including the new dismiss-closed-branches migration), then opens a Store which runs the remaining migrations. Verifies that migration 20 is correctly applied and recorded even when the schema changes from the old version 19 are already present.
  • Existing allowlist tests gain assertions for list_branch_inbox_allowlist_npubs, verifying that forge-write-only users appear in the branch inbox list but not in the active chat list.

web.rs:

  • managed_allowlist_backfills_only_for_new_reviewable_entries (renamed from managed_allowlist_backfills_only_for_new_active_entries): Extended with cases for forge-write-only users, including verifying that backfill doesn't trigger when a user already had forge-write access.
  • forge_only_writer_can_list_branch_inbox: End-to-end test that creates a branch with a ready artifact, adds a forge-write-only user, backfills their inbox, then verifies that both api_inbox_list_handler and api_inbox_count_handler return 200 OK for that user.

worker.rs:

  • The existing populate_ready_branch_inbox test adds a forge_writer_npub with active=false, can_forge_write=true and asserts the total recipient count increases from 3 to 4.

Diff