chore: hapus implementasi OAuth/session lama yang sudah digantikan zesdex-iam

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
asepharyanaandClaude Sonnet 5 committed 2026-07-17 09:08:41 +07:00
1 parent add6845edf
commit f51a32569f
13 files changed
+3700 -770

No files matched your search

@@ -0,0 +1,649 @@
# CMS Wiring: Conversation + Rewind Blob Store Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the SQLite-backed `messages`/`archives`/`blobs` tables (`crates/zesdex-backend/src/model/msglog/`) with `zesdex-cms`'s `Conversation`/`JsonConversationRepository` for message content, plus a brand-new file-based blob repository for rewind snapshots (no `zesdex-cms` equivalent existed before this plan). Fix the `ChatMessage` type collision between `zesdex-cms`'s local duplicate and the canonical `zesdex_entities::seaorm::common::message::ChatMessage` used everywhere else in theLine truncated
**Architecture:** Research confirmed the `messages` table is write-only today (archived but never read back to restore a session) and the `archives` table is created but **never populated by any code path** — both can be retired with zero behavior loss. The `blobs` table is the one genuinely load-bearing piece (rewind feature reads it back) and needs a real, tested replacement — a new `RewindBlobRepository` trait + `FileRewindBlobRepository` impl added to `zesdex-cms`, storing raw bytes as Line truncated
**Tech Stack:** Rust, Cargo workspace (`zesdex-cms`, `zesdex-backend`, `zesdex-entities`).
## Global Constraints
- No `#[allow(...)]` additions beyond what's already in touched files.
- Tests are inline `#[cfg(test)] mod tests`.
- Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` before each commit.
- This plan deliberately does **not** attempt to migrate historical data out of any existing `messages.sqlite` files — since the `messages`/`archives` tables were never read back by any code path, there is nothing meaningful to migrate. Existing `messages.sqlite` files are simply left on disk, unused, after this plan (a future cleanup could delete them, but doing so isn't required for correctness).
---
### Task 1: Reconcile the `ChatMessage`/`Role` type collision in `zesdex-cms`
**Context:** `crates/zesdex-cms/src/domain/conversation.rs` currently defines its own `Role`/`ChatMessage` (with `tool_calls: Option<Vec<serde_json::Value>>`, untyped) instead of reusing `zesdex_entities::seaorm::common::message::{Role, ChatMessage}` (the canonical type used throughout `zesdex-backend`, with `tool_calls: Option<Vec<ToolCall>>`, strongly typed). `zesdex-cms` already depends on `zesdex-entities` (confirmed in `Cargo.toml`), so this is a small, surgical fix.
**Files:**
- Modify: `crates/zesdex-cms/src/domain/conversation.rs`
- Modify: `crates/zesdex-cms/src/application/conversation_service.rs` (import path only)
- Modify: `crates/zesdex-cms/src/domain/service.rs` (import path only)
**Interfaces:**
- Produces: `zesdex_cms::domain::conversation::{Conversation, ChatMessage, Role}` where `ChatMessage`/`Role` are now re-exports of the canonical entities type — anything constructing a `zesdex_cms::domain::conversation::ChatMessage` is now interchangeable with `zesdex_entities::seaorm::common::message::ChatMessage` used elsewhere in `zesdex-backend`.
- [ ] **Step 1: Write the failing test proving type interchangeability**
Add to `crates/zesdex-cms/src/domain/conversation.rs`'s `#[cfg(test)] mod tests` (create if absent):
```rust
#[test]
fn chat_message_is_the_canonical_entities_type() {
// This is a compile-time proof more than a runtime assertion: if
// `zesdex_cms::domain::conversation::ChatMessage` were still a
// distinct local type, this line would fail to compile.
let canonical = zesdex_entities::seaorm::common::message::ChatMessage::user("hi");
let via_cms: ChatMessage = canonical;
assert_eq!(via_cms.content.as_deref(), Some("hi"));
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test -p zesdex-cms chat_message_is_the_canonical -- --nocapture`
Expected: compile error — `serde_json::Value` (cms's old `tool_calls` field type) vs `ChatMessage::user`'s type won't unify, or a straightforward type mismatch.
- [ ] **Step 3: Replace the local `Role`/`ChatMessage` with re-exports**
In `crates/zesdex-cms/src/domain/conversation.rs`, delete the entire local `Role` enum and `ChatMessage` struct + impl block (the definitions, constructors `user`/`assistant`/`system`/`tool`), and replace the top of the file with:
```rust
//! Pure Conversation entity — in-memory message history plus system prompt
//! and LLM generation parameters.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`ConversationRepository`](super::repository::ConversationRepository).
//!
//! `ChatMessage`/`Role` are re-exported from `zesdex-entities` rather than
//! duplicated here, so a `Conversation` built by this crate is
//! interchangeable with the `ChatMessage` type used throughout
//! `zesdex-backend`'s provider/tool-execution layer.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use serde::{Deserialize, Serialize};
pub use zesdex_entities::seaorm::common::message::{ChatMessage, Role};
```
Keep the `Conversation` struct, `impl Conversation` block (`new`, `push`, `rebuild_system`, `to_api_messages`, `len`, `is_empty`) unchanged below this — they only reference `ChatMessage`/`Role` by name, which now resolve to the re-exported canonical types.
- [ ] **Step 4: Fix any now-broken references in the same crate**
Run: `cargo build -p zesdex-cms 2>&1 | head -60`
If `application/conversation_service.rs` or `domain/service.rs` import `ChatMessage`/`Role` via `use super::conversation::{ChatMessage, Conversation}` or similar — these continue to work unchanged since the names are still exported from `domain::conversation`, just backed by a different underlying type now. Only fix compile errors that actually appear; do not preemptively touch files the build doesn't flag.
- [ ] **Step 5: Run the test to verify it passes**
Run: `cargo test -p zesdex-cms chat_message_is_the_canonical -- --nocapture`
Expected: pass.
- [ ] **Step 6: Run the crate's full test suite and clippy**
Run: `cargo test -p zesdex-cms && cargo clippy -p zesdex-cms -- -D warnings`
Expected: all pass.
- [ ] **Step 7: Commit**
```bash
git add crates/zesdex-cms
git commit -m "fix(cms): satukan ChatMessage/Role Conversation dengan tipe kanonik zesdex-entities"
```
---
### Task 2: Add `RewindBlobRepository` to `zesdex-cms`
**Files:**
- Modify: `crates/zesdex-cms/src/domain/repository.rs`
- Create: `crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs`
- Modify: `crates/zesdex-cms/src/infrastructure/persistence/mod.rs`
- Modify: `crates/zesdex-cms/Cargo.toml` (add `hex` and `chrono` if not already present — `chrono` is already a dependency per the crate's existing `Cargo.toml`; confirm `hex` with `grep hex crates/zesdex-cms/Cargo.toml` and add `hex.workspace = true` if missing)
**Interfaces:**
- Produces: `pub trait RewindBlobRepository { fn store_blob(&self, session_dir: &Path, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()>; fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>>; fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>>; }` and `pub struct FileRewindBlobRepository` — used by Task 4.
- [ ] **Step 1: Add the trait**
In `crates/zesdex-cms/src/domain/repository.rs`, add:
```rust
/// Repository for rewind-snapshot binary blobs, keyed by an arbitrary
/// caller-supplied key (e.g. a tool-call id) within a session.
pub trait RewindBlobRepository {
/// Store (or overwrite) a blob under `blob_key` for this session.
fn store_blob(&self, session_dir: &Path, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> anyhow::Result<()>;
/// Retrieve a blob's bytes by key, or `None` if not found.
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> anyhow::Result<Option<Vec<u8>>>;
/// List all blob keys for this session, oldest first.
fn list_blob_keys(&self, session_dir: &Path) -> anyhow::Result<Vec<String>>;
}
```
- [ ] **Step 2: Write the failing tests**
Create `crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs` with:
```rust
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("zesdex-cms-blob-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn store_and_retrieve_roundtrip() {
let dir = tmp_dir();
let repo = FileRewindBlobRepository::new();
repo.store_blob(&dir, "tool-call-1", b"hello world", Some("text/plain")).unwrap();
let bytes = repo.retrieve_blob(&dir, "tool-call-1").unwrap();
assert_eq!(bytes, Some(b"hello world".to_vec()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn retrieve_missing_key_returns_none() {
let dir = tmp_dir();
let repo = FileRewindBlobRepository::new();
assert_eq!(repo.retrieve_blob(&dir, "no-such-key").unwrap(), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_blob_keys_returns_oldest_first() {
let dir = tmp_dir();
let repo = FileRewindBlobRepository::new();
repo.store_blob(&dir, "first", b"a", None).unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
repo.store_blob(&dir, "second", b"b", None).unwrap();
let keys = repo.list_blob_keys(&dir).unwrap();
assert_eq!(keys, vec!["first".to_string(), "second".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn overwriting_a_key_keeps_only_the_latest_entry_in_the_listing() {
let dir = tmp_dir();
let repo = FileRewindBlobRepository::new();
repo.store_blob(&dir, "k", b"v1", None).unwrap();
repo.store_blob(&dir, "k", b"v2", None).unwrap();
let keys = repo.list_blob_keys(&dir).unwrap();
assert_eq!(keys, vec!["k".to_string()], "key must appear exactly once even after being overwritten");
assert_eq!(repo.retrieve_blob(&dir, "k").unwrap(), Some(b"v2".to_vec()));
let _ = std::fs::remove_dir_all(&dir);
}
}
```
(Requires `uuid` — already a `zesdex-cms` dependency per its `Cargo.toml`.)
- [ ] **Step 3: Run the tests to verify they fail**
Run: `cargo test -p zesdex-cms rewind_blob_repo:: 2>&1 | head -20`
Expected: compile error (`FileRewindBlobRepository` doesn't exist yet).
- [ ] **Step 4: Implement `FileRewindBlobRepository`**
Add above the test module in the same file:
```rust
//! Filesystem-backed `RewindBlobRepository` implementation.
//!
//! Blob bytes are stored at `<session_dir>/blobs/<hex(key)>.bin` (the key
//! is hex-encoded as the filename to sidestep any path-traversal/invalid-
//! filename-character concerns entirely, mirroring the simplicity of
//! `Memory::slugify` elsewhere in this crate but without needing a
//! human-readable filename). Key/ordering/mime-type metadata lives in an
//! append-only `<session_dir>/blobs/index.jsonl`, one JSON line per
//! `store_blob` call — the same JSONL-index pattern already used by
//! `EditLogRepository`. `list_blob_keys` de-duplicates by keeping each
//! key's *last* index line (so overwriting a key doesn't produce a
//! duplicate listing entry) and returns keys ordered by first-seen
//! `created_at` ascending (oldest first), matching the previous
//! `SQLite`-backed `ORDER BY created_at ASC` behavior.
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::domain::repository::RewindBlobRepository;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BlobIndexEntry {
key: String,
mime_type: Option<String>,
created_at: i64,
}
/// Concrete filesystem rewind-blob repository.
#[derive(Debug, Clone, Default)]
pub struct FileRewindBlobRepository;
impl FileRewindBlobRepository {
/// Create a new filesystem rewind-blob repository.
pub fn new() -> Self {
Self
}
fn blobs_dir(session_dir: &Path) -> std::path::PathBuf {
session_dir.join("blobs")
}
fn blob_file_path(session_dir: &Path, blob_key: &str) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join(format!("{}.bin", hex::encode(blob_key.as_bytes())))
}
fn index_path(session_dir: &Path) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join("index.jsonl")
}
}
impl RewindBlobRepository for FileRewindBlobRepository {
fn store_blob(&self, session_dir: &Path, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
let blobs_dir = Self::blobs_dir(session_dir);
std::fs::create_dir_all(&blobs_dir)
.with_context(|| format!("failed to create blobs dir '{}'", blobs_dir.display()))?;
let path = Self::blob_file_path(session_dir, blob_key);
let tmp = path.with_extension("bin.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, &path)?;
let entry = BlobIndexEntry {
key: blob_key.to_string(),
mime_type: mime_type.map(String::from),
created_at: chrono::Utc::now().timestamp_millis(),
};
let index_path = Self::index_path(session_dir);
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&index_path)
.with_context(|| format!("failed to open blob index '{}'", index_path.display()))?;
writeln!(f, "{}", serde_json::to_string(&entry)?)?;
f.sync_all()?;
Ok(())
}
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>> {
let path = Self::blob_file_path(session_dir, blob_key);
if !path.exists() {
return Ok(None);
}
Ok(Some(std::fs::read(&path)?))
}
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>> {
let index_path = Self::index_path(session_dir);
let Ok(content) = std::fs::read_to_string(&index_path) else {
return Ok(Vec::new());
};
// Keep only the last occurrence of each key (later overwrites win),
// but remember first-seen order for the final ascending sort.
let mut first_seen_order: Vec<String> = Vec::new();
let mut latest_by_key: std::collections::HashMap<String, BlobIndexEntry> = std::collections::HashMap::new();
for line in content.lines() {
let Ok(entry) = serde_json::from_str::<BlobIndexEntry>(line) else {
continue;
};
if !latest_by_key.contains_key(&entry.key) {
first_seen_order.push(entry.key.clone());
}
latest_by_key.insert(entry.key.clone(), entry);
}
let mut entries: Vec<BlobIndexEntry> = first_seen_order
.into_iter()
.filter_map(|k| latest_by_key.get(&k).cloned())
.collect();
entries.sort_by_key(|e| e.created_at);
Ok(entries.into_iter().map(|e| e.key).collect())
}
}
```
- [ ] **Step 5: Register the module**
In `crates/zesdex-cms/src/infrastructure/persistence/mod.rs`, add:
```rust
pub mod rewind_blob_repo;
```
- [ ] **Step 6: Run the tests to verify they pass**
Run: `cargo test -p zesdex-cms rewind_blob_repo:: -- --nocapture`
Expected: all 4 tests pass.
- [ ] **Step 7: Run clippy**
Run: `cargo clippy -p zesdex-cms -- -D warnings`
Expected: no new warnings.
- [ ] **Step 8: Commit**
```bash
git add crates/zesdex-cms/src/domain/repository.rs crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs crates/zesdex-cms/src/infrastructure/persistence/mod.rs crates/zesdex-cms/Cargo.toml
git commit -m "feat(cms): tambahkan RewindBlobRepository berbasis file (pengganti tabel blobs SQLite)"
```
---
### Task 3: Rewire message archiving (`archive_message`) to `Conversation`
**Files:**
- Modify: `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines ~695-708 `spawn_turn`, ~727-744 `TurnCtx`, ~862-873 `archive_message`, plus all 7 call sites at lines 939, 1105, 1295, 1387, 1421, 1426, 1458 — re-confirm line numbers first since Task 5 of the OAuth/session plan and Task 5 of the settings/appconfig/memory/editlog plan may have shifted this file)
**Interfaces:**
- Consumes: `zesdex_cms::domain::conversation::{Conversation, ChatMessage}` (Task 1), `zesdex_cms::infrastructure::persistence::conversation_repo::JsonConversationRepository`, `zesdex_cms::domain::repository::ConversationRepository`.
- Produces: `TurnCtx.conversation: Option<Arc<Mutex<Conversation>>>` (replaces `TurnCtx.db: Option<Arc<Mutex<rusqlite::Connection>>>`).
- [ ] **Step 1: Confirm current line numbers**
Run: `grep -n "fn spawn_turn\|struct TurnCtx\|fn archive_message\|open_or_create\|tc\.db\.as_ref" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
- [ ] **Step 2: Replace the per-turn connection setup in `spawn_turn`**
Replace:
```rust
let db = crate::model::msglog::open_or_create(&edit_session_dir)
.ok()
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));
```
with:
```rust
let conversation = {
let repo = zesdex_cms::infrastructure::persistence::conversation_repo::JsonConversationRepository::new();
let conv = repo.load(&edit_session_dir).unwrap_or_else(|_| {
zesdex_cms::domain::conversation::Conversation::new(String::new(), session_id.clone())
});
Some(std::sync::Arc::new(std::sync::Mutex::new(conv)))
};
```
Replace the `TurnCtx` struct literal's `db,` field with `conversation,`.
- [ ] **Step 3: Update the `TurnCtx` struct definition**
Replace:
```rust
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
```
with:
```rust
conversation: Option<std::sync::Arc<std::sync::Mutex<zesdex_cms::domain::conversation::Conversation>>>,
```
- [ ] **Step 4: Rewrite `archive_message`**
Replace:
```rust
fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
if let Some(arc) = db {
if let Ok(conn) = arc.lock() {
let _ = crate::model::msglog::insert_message(&conn, session_id, msg);
}
}
}
```
with:
```rust
/// Persist a `ChatMessage` to the session's `Conversation`, if one is
/// available for this turn.
///
/// Flow: if `conversation` is `Some`, lock the mutex, push the message,
/// and rewrite `conversation.json` in full. Errors are silently ignored
/// (matches the previous `SQLite`-backed behavior, which also swallowed
/// insert failures).
fn archive_message(
conversation: Option<&std::sync::Arc<std::sync::Mutex<zesdex_cms::domain::conversation::Conversation>>>,
session_dir: &std::path::Path,
msg: &ChatMessage,
) {
if let Some(arc) = conversation {
if let Ok(mut conv) = arc.lock() {
conv.push(msg.clone());
let repo = zesdex_cms::infrastructure::persistence::conversation_repo::JsonConversationRepository::new();
let _ = repo.save(session_dir, &conv);
}
}
}
```
- [ ] **Step 5: Update all 7 call sites**
Run: `grep -n "archive_message(tc.db.as_ref()" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
At each of the 7 matched lines, replace `archive_message(tc.db.as_ref(), &tc.session_id, &<msg_var>)` with `archive_message(tc.conversation.as_ref(), &tc.edit_log_session_dir, &<msg_var>)` (keep whatever the actual message-variable name is at each site — `sys`, `pipeline_msg`, `response`, `review_msg`, `tool_msg`, `msg` per the research brief — only the first two arguments change).
- [ ] **Step 6: Build**
Run: `cargo check -p zesdex-backend`
Expected: no errors (beyond anything Task 4's blob work below still needs to touch in the same file — if this task is done independently, `store_blob` call sites will still fail to compile at this point; that's expected and resolved by Task 4).
- [ ] **Step 7: Commit**
```bash
git add crates/zesdex-backend/src/app/runtime/actions/mod.rs
git commit -m "refactor(backend): alihkan archive_message dari SQLite messages table ke zesdex-cms Conversation"
```
---
### Task 4: Rewire rewind blob storage to `FileRewindBlobRepository`
**Files:**
- Modify: `crates/zesdex-backend/src/app/subagent/engine.rs` (2 `store_blob` call sites, confirmed at line 531-533 and a second one — re-grep to find both)
- Modify: `crates/zesdex-backend/src/app/mode/rewind.rs` (`rewind_count`, `rewind_to`, plus the `open_session_db` helper and the still-existing store_blob call site inside `execute_one_tool`/wherever the second engine.rs call lives)
**Interfaces:**
- Consumes: `zesdex_cms::infrastructure::persistence::rewind_blob_repo::FileRewindBlobRepository`, `zesdex_cms::domain::repository::RewindBlobRepository`.
- Produces: nothing new for other tasks — this is the last consumer of the old `msglog::blobs` module.
- [ ] **Step 1: Find both `store_blob` call sites in `engine.rs`**
Run: `grep -n -B6 "store_blob" crates/zesdex-backend/src/app/subagent/engine.rs`
- [ ] **Step 2: Replace each `store_blob` call site**
Replace (pattern applies to both sites, adjusting the surrounding variable names per the actual code read in Step 1):
```rust
let _ = crate::model::msglog::store_blob(
&conn, session_id, &tool_call.id, &bytes, None,
);
```
with:
```rust
let _ = zesdex_cms::infrastructure::persistence::rewind_blob_repo::FileRewindBlobRepository::new()
.store_blob(&ctx.session_dir, &tool_call.id, &bytes, None);
```
(Uses `RewindBlobRepository::store_blob` — add `use zesdex_cms::domain::repository::RewindBlobRepository;` to the file's imports. Note this drops the now-unneeded `conn`/`session_id`-derived-from-directory-name dance since the new repository takes `session_dir` directly — if the surrounding code only opened `conn` for this call, remove the now-dead connection-opening code too after confirming via Step 1's grep that nothing else in the same scope still needs it.)
- [ ] **Step 3: Rewrite `rewind.rs`'s `open_session_db` usage**
Read the whole file first: `cat crates/zesdex-backend/src/app/mode/rewind.rs`
Replace `rewind_count`:
```rust
pub fn rewind_count(state: &AppStateRest) -> usize {
let Ok(conn) = open_session_db(&state.session_dir) else {
return 0;
};
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
.ok()
.map_or(0, |keys| keys.len())
}
```
with:
```rust
pub fn rewind_count(state: &AppStateRest) -> usize {
zesdex_cms::infrastructure::persistence::rewind_blob_repo::FileRewindBlobRepository::new()
.list_blob_keys(&state.session_dir)
.ok()
.map_or(0, |keys| keys.len())
}
```
Replace the body of `rewind_to` (the `open_session_db` call plus `list_blob_keys`/`retrieve_blob` calls):
```rust
let conn = match open_session_db(&state.session_dir) { /* ... */ };
let keys = match crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) { /* ... */ };
/* ... */
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key) { /* ... */ };
```
with (dropping the `conn`/`open_session_db` step entirely — the file-based repository needs no connection object, just `&state.session_dir`):
```rust
let repo = zesdex_cms::infrastructure::persistence::rewind_blob_repo::FileRewindBlobRepository::new();
let keys = match repo.list_blob_keys(&state.session_dir) {
Ok(k) => k,
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to list snapshots: {e}"),
));
state.dirty = true;
return;
}
};
if keys.is_empty() || index >= keys.len() {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Warning,
"No snapshot available at that index".to_string(),
));
state.dirty = true;
return;
}
let blob_key = &keys[index];
let bytes = match repo.retrieve_blob(&state.session_dir, blob_key) {
Ok(Some(b)) => b,
Ok(None) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
"Snapshot data not found".to_string(),
));
state.dirty = true;
return;
}
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("Failed to retrieve snapshot: {e}"),
));
state.dirty = true;
return;
}
};
```
(Keep whatever code follows `bytes` unchanged — the actual file-restoration logic doesn't depend on how `bytes` was fetched.)
- [ ] **Step 4: Delete the now-unused `open_session_db` helper**
If `open_session_db` (used only by the two call sites just replaced) has no other callers after Step 3 — verify with `grep -n "open_session_db" crates/zesdex-backend/src/app/mode/rewind.rs` — delete its definition entirely.
- [ ] **Step 5: Update the module doc comment**
Replace the file's top doc comment:
```rust
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's `SQLite` blob store.
```
with:
```rust
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's file-based rewind blob store (`<session_dir>/blobs/`).
```
- [ ] **Step 6: Build**
Run: `cargo check -p zesdex-backend`
Expected: no errors.
- [ ] **Step 7: Run the workspace test suite**
Run: `cargo test --workspace`
Expected: all pass.
- [ ] **Step 8: Manual smoke test**
In the TUI: perform a file edit (triggers a pre-edit snapshot store), open the Rewind overlay, confirm the snapshot count and list are correct, and restore the file — confirm the restored content matches the pre-edit version exactly.
- [ ] **Step 9: Commit**
```bash
git add crates/zesdex-backend/src/app/subagent/engine.rs crates/zesdex-backend/src/app/mode/rewind.rs
git commit -m "refactor(backend): alihkan penyimpanan blob rewind ke FileRewindBlobRepository"
```
---
### Task 5: Delete the now-dead `msglog` SQLite module
**Files:**
- Delete: `crates/zesdex-backend/src/model/msglog/{mod.rs,schema.rs,query.rs,blobs.rs}`
- Modify: `crates/zesdex-backend/src/model/mod.rs` (remove `pub mod msglog;`)
- Modify: `crates/zesdex-backend/src/main.rs` (line ~236, `edit_count: state.edit_log.len() as u32,` — confirm this doesn't reference `msglog` directly; if it only reads `state.edit_log`, no change needed here)
**Interfaces:** none — pure deletion after Tasks 3-4 remove every reference.
- [ ] **Step 1: Verify zero remaining references**
Run: `grep -rln "model::msglog\|msglog::" crates/zesdex-backend/src`
Expected: no output (only the `model/mod.rs` declaration itself, addressed in Step 3).
- [ ] **Step 2: Delete the files**
```bash
git rm -r crates/zesdex-backend/src/model/msglog
```
- [ ] **Step 3: Remove the module declaration**
In `crates/zesdex-backend/src/model/mod.rs`, remove:
```rust
pub mod msglog;
```
- [ ] **Step 4: Build the whole workspace**
Run: `cargo build --workspace`
Expected: no errors. If `rusqlite` was only pulled into `zesdex-backend` for this module, `cargo build` will still succeed since `rusqlite` remains a workspace dependency used elsewhere (`zesdex-libs::database.rs`) — no `Cargo.toml` change needed here; confirm with `grep -rln "rusqlite" crates/zesdex-backend/src` that no other file in this crate still needs it, and if truly zero remaining uses, remove `rusqlite` from `crates/zesdex-backend/Cargo.toml`'s `[dependencies]` as a final cleanup (onLine truncated
- [ ] **Step 5: Run the full test suite and clippy**
Run: `cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`
Expected: all pass, no new warnings.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "chore: hapus modul msglog SQLite lama (digantikan Conversation + RewindBlobRepository)"
```
@@ -0,0 +1,737 @@
# CMS Wiring: Settings, AppConfig, Memory, EditLog Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace `zesdex-backend`'s use of `zesdex_entities::seaorm::common::{settings,app_config,memory,edit_log}` (inherent-method I/O) with the previously-orphaned `zesdex-cms` crate's repository-based equivalents (`JsonSettingsRepository`, `JsonAppConfigRepository`, `MarkdownMemoryRepository`, `JsonlEditLogRepository`), which are on-disk-format-compatible drop-ins. Fix the one real defect found in `zesdex-cms::Settings` along the way (a missing `#[serde(default)]` that would hard-fail loadLine truncated
**Architecture:** `zesdex-backend` call sites move from `Type::static_method()` to `repository_instance.method(&base_dir, ...)`. Since `zesdex-cms`'s application-service layer (`SettingsServiceImpl`, `MemoryServiceImpl`) doesn't cover every read pattern the backend needs (no bare `AppConfig` getter, no single-memory read, no `EditLogService` at all), most call sites construct and use the concrete `Json*Repository`/`MarkdownMemoryRepository`/`JsonlEditLogRepository` types directly rather than goLine truncated
**Tech Stack:** Rust, Cargo workspace (`zesdex-cms`, `zesdex-backend`, `zesdex-entities`).
## Global Constraints
- On-disk formats are confirmed compatible for all four entities (same JSON/markdown/JSONL shape and file paths) — this plan is a call-site migration, **not** a data migration. No existing user `settings.json`/`app_config.json`/`*.md` memory files/`edits.jsonl` need to change.
- `EditLog`'s domain shape genuinely differs between the old (`entries` + `path`, disk I/O on construction) and new (`entries` only, disk I/O via `EditLogRepository::open`) versions — every call site becomes fallible (`Result`) where it was previously infallible.
- No new `#[allow(...)]` attributes. Existing ones in touched files are out of scope (handled by `2026-07-16-convention-cleanup-docs.md`).
- Tests are inline `#[cfg(test)] mod tests`, per CLAUDE.md.
- Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` before each commit.
- Base directory resolution: everywhere the old code called the zero-argument `Settings::load()`/`Settings::save()`/`AppConfig::load()` (which internally called `zesdex_entities::seaorm::common::store::Store::new()` to get `base_dir`), the replacement must call `zesdex_entities::seaorm::common::store::Store::new().base_dir` explicitly and pass it to the `zesdex-cms` repository — this reproduces the exact same directory, verified identical during planning.
---
### Task 1: Fix the `hive_mind_node_timeout_ms` serde-default gap in `zesdex-cms::Settings`
**Context:** `crates/zesdex-entities/src/seaorm/common/settings.rs:75` has `#[serde(default = "default_hive_mind_node_timeout_ms")]` on this field; `crates/zesdex-cms/src/domain/settings.rs`'s copy does not. Without this, any settings.json written before this field existed (or any settings.json missing it for any reason) will hard-fail `JsonSettingsRepository::load` with a parse error, whereas the old `Settings::load()` silently defaulted on **any** failure. Fix both the missing default and resLine truncated
**Files:**
- Modify: `crates/zesdex-cms/src/domain/settings.rs`
- Modify: `crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs`
**Interfaces:**
- Produces: `Settings::default()` unchanged in value; `JsonSettingsRepository::load` becomes tolerant of parse failures (still `Result`-returning, but only returns `Err` for I/O errors other than "not found" or "malformed JSON" — matching the old infallible-except-I/O-permission-errors behavior as closely as a `Result`-based API can).
- [ ] **Step 1: Write the failing test**
Add to `crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs`'s `#[cfg(test)] mod tests` (create it if absent — check first: `grep -n "mod tests" crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs`):
```rust
#[test]
fn load_defaults_hive_mind_timeout_when_field_missing_from_old_settings_json() {
let dir = std::env::temp_dir().join(format!("zesdex-cms-settings-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
// Simulate a settings.json written before `hive_mind_node_timeout_ms` existed.
std::fs::write(
dir.join("settings.json"),
r#"{"internet_mode":"Off","provider":"zen","model":"m","api_keys":{},"max_tokens":null,"temperature":null,"review_max_lessons_per_run":5,"adaptive_review_max_skip":3,"verify_command":null,"verify_timeout_ms":30000,"workflow_max_concurrency":5,"review_enabled":true,"session_archive_enabled":true,"lsp_auto_provision":true,"lsp_languages":[]}"#,
).unwrap();
let repo = JsonSettingsRepository::new();
let settings = repo.load(&dir).expect("load must not fail on a pre-existing settings.json missing the new field");
assert_eq!(settings.hive_mind_node_timeout_ms, 600_000);
let _ = std::fs::remove_dir_all(&dir);
}
```
(Requires `uuid` as a dev-dependency of `zesdex-cms` — check first: `grep uuid crates/zesdex-cms/Cargo.toml`; add `uuid = { workspace = true }` under `[dev-dependencies]` if missing, creating that section if it doesn't exist.)
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test -p zesdex-cms load_defaults_hive_mind -- --nocapture`
Expected: fails with a JSON parse/missing-field error.
- [ ] **Step 3: Add the serde default to the domain type**
In `crates/zesdex-cms/src/domain/settings.rs`, add above the `Settings` struct:
```rust
fn default_hive_mind_node_timeout_ms() -> u64 {
600_000
}
```
And annotate the field:
```rust
#[serde(default = "default_hive_mind_node_timeout_ms")]
pub hive_mind_node_timeout_ms: u64,
```
- [ ] **Step 4: Restore full parse-failure tolerance in the repository**
In `crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs`, update `load`:
```rust
fn load(&self, base_dir: &Path) -> Result<Settings> {
let path = base_dir.join("settings.json");
match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str(&s) {
Ok(settings) => Ok(settings),
Err(e) => {
tracing::warn!(
"settings.json at '{}' failed to parse ({e}); falling back to defaults",
path.display()
);
Ok(Settings::default())
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!("settings.json not found, using defaults");
Ok(Settings::default())
}
Err(e) => Err(anyhow::anyhow!("failed to read settings.json: {e}")),
}
}
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `cargo test -p zesdex-cms load_defaults_hive_mind -- --nocapture`
Expected: pass.
- [ ] **Step 6: Run the crate's full test suite and clippy**
Run: `cargo test -p zesdex-cms && cargo clippy -p zesdex-cms -- -D warnings`
Expected: all pass, no new warnings.
- [ ] **Step 7: Commit**
```bash
git add crates/zesdex-cms/src/domain/settings.rs crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs
git commit -m "fix(cms): perbaiki serde default hive_mind_node_timeout_ms & toleransi parse gagal di Settings"
```
---
### Task 2: Swap `Settings` call sites to `JsonSettingsRepository`
**Files (every one confirmed by research — swap all):**
- `crates/zesdex-backend/src/app/state/rest.rs` (lines 20, 49, 89, 103, 170)
- `crates/zesdex-backend/src/bin/seed.rs` (line 12)
- `crates/zesdex-backend/src/app/subagent/engine.rs` (lines 61, 64-84, 353 doc comment)
- `crates/zesdex-backend/src/app/workflow/hive_mind.rs` (lines 255, 281-283, 367 doc comments)
- `crates/zesdex-backend/src/app/runtime/context/window.rs` (lines 9, 19-25, 46/56/79 test-only)
- `crates/zesdex-backend/src/app/mode/settings.rs` (lines 6, 16-22)
- `crates/zesdex-backend/src/controller/input.rs` (lines 339, 354, 357-361, 393-407)
- `crates/zesdex-backend/src/view/mod.rs` (lines 152-174, 701-713 — read-only, no method-call change needed beyond the type import)
- `crates/zesdex-backend/src/view/status.rs` (lines 68, 86-93 — read-only)
- `crates/zesdex-backend/src/app/review/mod.rs` (lines 79, 85, 405-406 — read-only)
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines 549, 633-671, 1615, 1758 — read-only)
- `crates/zesdex-backend/src/main.rs` (lines 141, 474, 641 — `.save()` calls)
**Interfaces:**
- Consumes: `zesdex_cms::domain::settings::Settings`, `zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository`, `zesdex_cms::domain::repository::SettingsRepository` (trait, for method resolution), `zesdex_entities::seaorm::common::store::Store` (for `base_dir` resolution).
- Produces: `AppStateRest.settings: zesdex_cms::domain::settings::Settings` (type changed from the old entities type — field-identical, so every **read-only** call site above needs only an import-path change, not a logic change).
- [ ] **Step 1: Update the type import everywhere it's read-only**
In each of `rest.rs`, `view/mod.rs`, `view/status.rs`, `app/review/mod.rs`, `app/runtime/actions/mod.rs`, replace:
```rust
use crate::model::settings::Settings;
```
with:
```rust
use zesdex_cms::domain::settings::Settings;
```
(For files that reference `Settings` only via `state.settings.<field>` without an explicit `use` for the type itself — confirm per-file with `grep -n "use.*settings::Settings\|model::settings" <file>` before editing — skip files where no explicit import exists, since `AppStateRest.settings`'s type change alone (Step 3 below) is what they actually depend on.)
- [ ] **Step 2: Update `app/mode/settings.rs` (mutates `Settings` in place, no I/O)**
Read the file first: `cat crates/zesdex-backend/src/app/mode/settings.rs`. Replace:
```rust
use crate::model::settings::{InternetMode, Settings};
```
with:
```rust
use zesdex_cms::domain::settings::{InternetMode, Settings};
```
`cycle_internet_mode(settings: &mut Settings)`'s body (mutating `settings.internet_mode` in a cycle) needs no logic change — `InternetMode` is field-identical between old and new.
- [ ] **Step 3: Update `AppStateRest` construction and field type**
In `crates/zesdex-backend/src/app/state/rest.rs`:
Replace the import (line 20):
```rust
use crate::model::settings::Settings;
```
with:
```rust
use zesdex_cms::domain::settings::Settings;
use zesdex_cms::domain::repository::SettingsRepository;
use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository;
```
Replace the field's type comment reference (line 49) — no change needed, `pub settings: Settings,` already resolves to the new import.
Replace construction (line 89):
```rust
let settings = Settings::load();
```
with:
```rust
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
let settings = JsonSettingsRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
```
- [ ] **Step 4: Update `controller/input.rs`'s `.save()` call sites**
Read the file first: `grep -n -B3 "\.settings\.save()" crates/zesdex-backend/src/controller/input.rs`
Both call sites (previously lines 361 and 407) currently do `let _ = state.settings.save();`. Since `Settings` no longer carries an inherent `save()` method, replace each with:
```rust
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.save(&state.store_base_dir(), &state.settings);
```
(Uses `state.store_base_dir()` — the existing helper on `AppStateRest`, confirmed to resolve to the same directory as `Store::new().base_dir` in normal operation — since these call sites already have `state: &mut AppStateRest` in scope, unlike `rest.rs::new()` which doesn't yet have a constructed `state` to call `.store_base_dir()` on.)
Add `use zesdex_cms::domain::repository::SettingsRepository;` to this file's imports if not already present after Step 1.
- [ ] **Step 5: Update `main.rs`'s three `.save()` call sites**
Read the file first: `grep -n -B2 "\.settings\.save()" crates/zesdex-backend/src/main.rs`
Replace each `let _ = state.settings.save();` (or `client_state.settings.save()`) with the same pattern as Step 4, substituting the correct state variable name at each site:
```rust
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.save(&state.store_base_dir(), &state.settings);
```
- [ ] **Step 6: Update `app/subagent/engine.rs`'s `resolve_provider_config()`**
Read the function first: `grep -n -A 30 "fn resolve_provider_config" crates/zesdex-backend/src/app/subagent/engine.rs`
Replace:
```rust
let settings = crate::model::settings::Settings::load();
```
with:
```rust
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
```
(Add `use zesdex_cms::domain::repository::SettingsRepository;` to this file's imports.) The subsequent field reads (`settings.api_keys`, `settings.provider`, `settings.model` at lines 64-84) need no change — same field names/types.
- [ ] **Step 7: Update `app/workflow/hive_mind.rs`'s `run_hive_mind()`**
Read the function first: `grep -n -A 5 "let settings = crate::model::settings::Settings::load" crates/zesdex-backend/src/app/workflow/hive_mind.rs`
Apply the identical substitution pattern from Step 6. Field reads `settings.hive_mind_node_timeout_ms`/`settings.workflow_max_concurrency` need no change.
- [ ] **Step 8: Update `app/runtime/context/window.rs`**
Read the file first: `cat crates/zesdex-backend/src/app/runtime/context/window.rs`
Replace the import (line 9):
```rust
use crate::model::settings::Settings;
```
with:
```rust
use zesdex_cms::domain::settings::Settings;
```
The `resolve(app_config: &AppConfig, settings: &Settings)` function signature/body and the three test-only `Settings::default()` constructions need no logic change — same type shape, `Default` still works identically after Task 1.
- [ ] **Step 9: Update `bin/seed.rs`**
Read the file first: `cat crates/zesdex-backend/src/bin/seed.rs`
Replace:
```rust
zesdex_entities::seaorm::common::settings::Settings::default()
```
with:
```rust
zesdex_cms::domain::settings::Settings::default()
```
- [ ] **Step 10: Remove the now-unused `model::settings` re-export**
In `crates/zesdex-backend/src/model/mod.rs`, remove:
```rust
pub mod settings {
pub use zesdex_entities::seaorm::common::settings::*;
}
```
- [ ] **Step 11: Verify no remaining references**
Run: `grep -rn "model::settings::" crates/zesdex-backend/src`
Expected: no output.
- [ ] **Step 12: Build and test**
Run: `cargo build --workspace && cargo test --workspace`
Expected: no errors, all tests pass.
- [ ] **Step 13: Manual smoke test**
Run: `cargo run -p zesdex-backend`. Confirm the TUI starts, the Settings overlay (per `view/mod.rs`) displays the current provider/model/flags correctly, and changing internet mode / API key / provider / model persists correctly across a restart (settings.json is written and re-read with the same values).
- [ ] **Step 14: Commit**
```bash
git add -A
git commit -m "refactor(backend): alihkan Settings ke zesdex-cms JsonSettingsRepository"
```
---
### Task 3: Swap `AppConfig` call sites to `JsonAppConfigRepository`
**Files:**
- `crates/zesdex-backend/src/app/state/rest.rs` (lines 18, 50, 90, 104)
- `crates/zesdex-backend/src/bin/seed.rs` (line 27)
- `crates/zesdex-backend/src/app/subagent/engine.rs` (lines 62, 69, 73)
- `crates/zesdex-backend/src/app/runtime/context/window.rs` (lines 8, 19-25, test sites)
- `crates/zesdex-backend/src/controller/input.rs` (lines 220, 252, 383, 385)
- `crates/zesdex-backend/src/view/mod.rs` (lines 710-711)
- `crates/zesdex-backend/src/view/status.rs` (line 68)
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines 548, 551, 635-659, 1755-1758)
**Interfaces:**
- Consumes: `zesdex_cms::domain::app_config::{AppConfig, ProviderConfig, ModelRole}`, `zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository`, `zesdex_cms::domain::repository::AppConfigRepository`.
- Produces: `AppStateRest.app_config: zesdex_cms::domain::app_config::AppConfig` (type changed, field-identical). `AppConfig` is never saved anywhere in `zesdex-backend` today (confirmed by research) — this task only needs `load`, no `save` call sites.
- [ ] **Step 1: Update read-only imports**
Same pattern as Task 2 Step 1: in each file that imports `crate::model::app_config::AppConfig`/`ProviderConfig`, replace with `zesdex_cms::domain::app_config::{AppConfig, ProviderConfig}` (add `ModelRole` too where `view/mod.rs`/`window.rs` need it).
- [ ] **Step 2: Update `AppStateRest` construction**
In `crates/zesdex-backend/src/app/state/rest.rs`, add to the import block from Task 2 Step 3:
```rust
use zesdex_cms::domain::app_config::AppConfig;
use zesdex_cms::domain::repository::AppConfigRepository;
use zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository;
```
Replace construction (line 90):
```rust
let app_config = AppConfig::load();
```
with:
```rust
let app_config = JsonAppConfigRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
```
(Reuses the `store_base_dir` local variable already introduced in Task 2 Step 3 — both `Settings` and `AppConfig` load from the same base directory.)
- [ ] **Step 3: Update `app/subagent/engine.rs`**
Apply the same substitution as Task 2 Step 6, adding the `AppConfig` load right after the `Settings` load using the same `store_base_dir`:
```rust
let app_config = zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
```
(Add `use zesdex_cms::domain::repository::AppConfigRepository;`.)
- [ ] **Step 4: Update `bin/seed.rs`**
Replace `zesdex_entities::seaorm::common::app_config::AppConfig::default()` with `zesdex_cms::domain::app_config::AppConfig::default()`.
- [ ] **Step 5: Remove the now-unused `model::app_config` re-export**
In `crates/zesdex-backend/src/model/mod.rs`, remove:
```rust
pub mod app_config {
pub use zesdex_entities::seaorm::common::app_config::*;
}
```
- [ ] **Step 6: Verify no remaining references, build, and test**
Run: `grep -rn "model::app_config::" crates/zesdex-backend/src` — expected no output.
Run: `cargo build --workspace && cargo test --workspace` — expected all pass.
- [ ] **Step 7: Manual smoke test**
Run the TUI, open the Model Selector overlay (Ctrl+P or equivalent per `resources.rs`), confirm the provider/model list still populates correctly from `app_config.json`, and that Claude-credential auto-detection (if `~/.claude/settings.json` exists on the test machine) still merges in correctly.
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "refactor(backend): alihkan AppConfig ke zesdex-cms JsonAppConfigRepository"
```
---
### Task 4: Swap `Memory` call sites to `MarkdownMemoryRepository`
**Files:**
- `crates/zesdex-backend/src/tool/memory/recall.rs` (lines 4, 44, 63, 69)
- `crates/zesdex-backend/src/tool/memory/remember.rs` (lines 4, 74, 79-96)
- `crates/zesdex-backend/src/tool/memory/forget.rs` (lines 4, 45)
- `crates/zesdex-backend/src/app/mode/learning.rs` (lines 56, 58)
- `crates/zesdex-backend/src/app/workflow/docs.rs` (line 34 — `slugify` only, no I/O)
- `crates/zesdex-backend/src/app/review/mod.rs` (lines 487, 491, 493-494)
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines 591, 797, 810, 833, 843)
**Interfaces:**
- Consumes: `zesdex_cms::domain::memory::Memory`, `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository`, `zesdex_cms::domain::repository::MemoryRepository`.
- Produces: nothing new for other tasks — `Memory` is a leaf entity with no state-struct field.
- [ ] **Step 1: `tool/memory/recall.rs`**
Read the file: `cat crates/zesdex-backend/src/tool/memory/recall.rs`
Replace:
```rust
use crate::model::memory::Memory;
```
with:
```rust
use zesdex_cms::domain::memory::Memory;
use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
```
Replace (line 44): `Memory::read(&ctx.memory_dir, name)` → `MarkdownMemoryRepository::new().load(&ctx.memory_dir, name)`
Replace (line 63): `Memory::list(&ctx.memory_dir)` → `MarkdownMemoryRepository::new().list(&ctx.memory_dir)`
Replace (line 69): `Memory::read(&ctx.memory_dir, name)` → `MarkdownMemoryRepository::new().load(&ctx.memory_dir, name)`
(Both old inherent methods and the new repository methods return `Result<Memory>`/`Result<Vec<String>>` respectively — signature shape at the call site is unchanged beyond the receiver.)
- [ ] **Step 2: `tool/memory/remember.rs`**
Read the file: `cat crates/zesdex-backend/src/tool/memory/remember.rs`
Same import swap as Step 1. Replace (line 74): `Memory::slugify(name)` → `Memory::slugify(name)` (unchanged — `slugify` remains an inherent method on the domain struct in `zesdex-cms`, per research). The struct-literal construction (lines 79-92) needs no change (field-identical). Replace (lines 94-96):
```rust
memory.write(&ctx.memory_dir)?;
```
with:
```rust
MarkdownMemoryRepository::new().save(&ctx.memory_dir, &memory)?;
```
- [ ] **Step 3: `tool/memory/forget.rs`**
Same import swap. Replace (line 45): `Memory::remove(&ctx.memory_dir, name)` → `MarkdownMemoryRepository::new().delete(&ctx.memory_dir, name)`.
- [ ] **Step 4: `app/mode/learning.rs`**
Read the file: `cat crates/zesdex-backend/src/app/mode/learning.rs`
Replace (line 56): `crate::model::memory::Memory::list(&state.memory_dir)` → `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().list(&state.memory_dir)`
Replace (line 58): `crate::model::memory::Memory::read(&state.memory_dir, &name)` → `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().load(&state.memory_dir, &name)`
(Add `use zesdex_cms::domain::repository::MemoryRepository;` at the top of the file.)
- [ ] **Step 5: `app/workflow/docs.rs`**
Replace (line 34): `crate::model::memory::Memory::slugify(user_request)` → `zesdex_cms::domain::memory::Memory::slugify(user_request)`. (Pure filename-generation helper, no repository/I/O involved — no other change needed.)
- [ ] **Step 6: `app/review/mod.rs`**
Read the surrounding code: `grep -n -B2 -A8 "model::memory::Memory::list(memory_dir)" crates/zesdex-backend/src/app/review/mod.rs`
Replace (line 487): `crate::model::memory::Memory::list(memory_dir)` → `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().list(memory_dir)`
Replace (line 491): `crate::model::memory::Memory::read(memory_dir, &name)` → `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().load(memory_dir, &name)`
Replace (lines 493-494):
```rust
mem.lifecycle = "stale".to_string();
mem.write(memory_dir)?;
```
with:
```rust
mem.lifecycle = "stale".to_string();
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().save(memory_dir, &mem)?;
```
- [ ] **Step 7: `app/runtime/actions/mod.rs`**
Read each site first: `grep -n -B2 -A2 "model::memory::Memory::" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
Apply the same `Memory::method(dir, ...)` → `MarkdownMemoryRepository::new().method(dir, ...)` substitution at all 5 remaining sites (lines 591, 797, 810, 833, 843), matching the method-name mapping: `remove`→`delete`, `list`→`list`, `read`→`load`.
- [ ] **Step 8: Add `use` statements**
Add `use zesdex_cms::domain::repository::MemoryRepository;` and `use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;` to `app/review/mod.rs` and `app/runtime/actions/mod.rs` (both already import many things — add alongside existing `use` block).
- [ ] **Step 9: Remove the now-unused `model::memory` re-export**
In `crates/zesdex-backend/src/model/mod.rs`, remove:
```rust
pub mod memory {
pub use zesdex_entities::seaorm::common::memory::*;
}
```
- [ ] **Step 10: Verify no remaining references, build, test**
Run: `grep -rn "model::memory::" crates/zesdex-backend/src` — expected no output.
Run: `cargo build --workspace && cargo test --workspace` — expected all pass.
- [ ] **Step 11: Manual smoke test**
In the TUI, invoke the `remember` tool to create a memory, `recall` it back, confirm the Learning overlay lists it, then `forget` it and confirm it's gone. Also confirm existing `.md` memory files from before this change (if any test fixtures exist) still load correctly (format compatibility check).
- [ ] **Step 12: Commit**
```bash
git add -A
git commit -m "refactor(backend): alihkan Memory ke zesdex-cms MarkdownMemoryRepository"
```
---
### Task 5: Swap `EditLog` call sites to `JsonlEditLogRepository`
**Context:** This is the most invasive of the four because every call site currently does `EditLog::new(dir)` (infallible, eager full-file read) and the new equivalent `JsonlEditLogRepository::open(dir)` returns `Result`. There are 7 distinct call sites plus one persistent field on `AppStateRest`.
**Files:**
- `crates/zesdex-backend/src/app/state/rest.rs` (lines 19, 58, 115)
- `crates/zesdex-backend/src/main.rs` (line 236 — reads `state.edit_log.len()`, no change needed beyond the type)
- `crates/zesdex-backend/src/app/subagent/engine.rs` (lines 571-580)
- `crates/zesdex-backend/src/app/mode/rewind.rs` (lines 103-114, 128-134)
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines 921, 1473-1475, 1487-1489, 1586-1597)
- `crates/zesdex-backend/src/model/mod.rs` (remove re-export)
**Interfaces:**
- Consumes: `zesdex_cms::domain::edit_log::{EditLog, EditLogEntry}`, `zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository`, `zesdex_cms::domain::repository::EditLogRepository`.
- Produces: `AppStateRest.edit_log: zesdex_cms::domain::edit_log::EditLog` (type changed — no `path` field this time, so anything reading `state.edit_log.path` would break; confirmed by research that no call site does this).
- [ ] **Step 1: Update `AppStateRest`**
In `crates/zesdex-backend/src/app/state/rest.rs`, replace the import (line 19):
```rust
use crate::model::editlog::EditLog;
```
with:
```rust
use zesdex_cms::domain::edit_log::EditLog;
use zesdex_cms::domain::repository::EditLogRepository;
use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository;
```
Replace construction (line 115):
```rust
edit_log: EditLog::new(session_dir),
```
with:
```rust
edit_log: JsonlEditLogRepository::new().open(session_dir).unwrap_or_else(|e| {
tracing::warn!("[state] failed to open edit log at '{}': {e}", session_dir.display());
EditLog::new()
}),
```
- [ ] **Step 2: `app/subagent/engine.rs`**
Read the site: `grep -n -B3 -A3 "EditLog::new(&ctx.session_dir)" crates/zesdex-backend/src/app/subagent/engine.rs`
Replace:
```rust
let mut el = crate::model::editlog::EditLog::new(&ctx.session_dir);
el.append(entry).ok();
```
with:
```rust
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(&ctx.session_dir) {
let _ = repo.append(&ctx.session_dir, &mut el, entry);
}
```
- [ ] **Step 3: `app/mode/rewind.rs` — logging the rewind operation**
Read the site: `grep -n -B3 -A10 "EditLog::new(&state.session_dir)" crates/zesdex-backend/src/app/mode/rewind.rs`
Replace the first site (previously lines 103-114):
```rust
let mut el = crate::model::editlog::EditLog::new(&state.session_dir);
let entry = crate::model::editlog::EditLogEntry { /* ... */ };
let _ = el.append(entry);
```
with:
```rust
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(&state.session_dir) {
let entry = zesdex_cms::domain::edit_log::EditLogEntry { /* same field values as before */ };
let _ = repo.append(&state.session_dir, &mut el, entry);
}
```
(Keep the exact same `EditLogEntry` field values from the original code — only the type path and the append mechanism change.)
- [ ] **Step 4: `app/mode/rewind.rs` — `find_edit_path`**
Read the site: `grep -n -B3 -A8 "fn find_edit_path" crates/zesdex-backend/src/app/mode/rewind.rs`
Replace:
```rust
let el = crate::model::editlog::EditLog::new(&state.session_dir);
```
with:
```rust
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&state.session_dir)
.unwrap_or_else(|_| zesdex_cms::domain::edit_log::EditLog::new());
```
The subsequent `el.entries.iter().rev().find(...)` (unchanged — `entries` is a public field on both old and new `EditLog`) needs no further change.
- [ ] **Step 5: `app/runtime/actions/mod.rs` — turn-start snapshot**
Read the site: `grep -n -B2 -A2 "let initial_edits" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
Replace:
```rust
let initial_edits = crate::model::editlog::EditLog::new(&tc.edit_log_session_dir).len();
```
with:
```rust
let initial_edits = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&tc.edit_log_session_dir)
.map(|el| el.entries.len())
.unwrap_or(0);
```
(`EditLog` in `zesdex-cms` has no `.len()` inherent method — check first: `grep -n "fn len\|impl EditLog" crates/zesdex-cms/src/domain/edit_log.rs`. If `.len()` doesn't exist, use `.entries.len()` as shown; if it does exist, use `el.len()` instead for consistency with the rest of the codebase's naming.)
- [ ] **Step 6: `app/runtime/actions/mod.rs` — turn-end diff**
Read the site: `grep -n -B2 -A10 "let final_edits" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
Replace:
```rust
let el = crate::model::editlog::EditLog::new(&tc.edit_log_session_dir);
let final_edits = el.len();
let total_edits_this_turn = final_edits.saturating_sub(initial_edits);
```
with:
```rust
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&tc.edit_log_session_dir)
.unwrap_or_else(|_| zesdex_cms::domain::edit_log::EditLog::new());
let final_edits = el.entries.len();
let total_edits_this_turn = final_edits.saturating_sub(initial_edits);
```
And immediately after (previously lines 1487-1489), the `.entries.iter().skip(initial_edits)` loop needs no change — same field access.
- [ ] **Step 7: `app/runtime/actions/mod.rs` — `execute_one_tool`'s primary write path**
Read the site: `grep -n -B3 -A5 "EditLog::new(session_dir)" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
Apply the same pattern as Step 2 (open + conditional append via the repository).
- [ ] **Step 8: Remove the now-unused `model::editlog` re-export**
In `crates/zesdex-backend/src/model/mod.rs`, remove:
```rust
pub mod editlog {
pub use zesdex_entities::seaorm::common::edit_log::*;
}
```
- [ ] **Step 9: Verify no remaining references**
Run: `grep -rn "model::editlog::" crates/zesdex-backend/src`
Expected: no output.
- [ ] **Step 10: Build and test**
Run: `cargo build --workspace && cargo test --workspace`
Expected: no errors, all tests pass.
- [ ] **Step 11: Manual smoke test**
In the TUI, make a file edit via the `edit`/`write` tool, confirm `state.edit_log` grows and `main.rs`'s IPC `edit_count` payload reflects it, then use the rewind overlay to confirm `find_edit_path` still correctly recovers the edited file's path and the rewind itself works end-to-end.
- [ ] **Step 12: Commit**
```bash
git add -A
git commit -m "refactor(backend): alihkan EditLog ke zesdex-cms JsonlEditLogRepository"
```
---
### Task 6: Delete the now-dead `zesdex_entities::seaorm::common::{settings,app_config,memory,edit_log}` modules
**Files:**
- Delete: `crates/zesdex-entities/src/seaorm/common/settings.rs`, `app_config.rs`, `memory.rs`, `edit_log.rs`
- Modify: `crates/zesdex-entities/src/seaorm/common/mod.rs` (remove their module declarations)
**Interfaces:** none — pure deletion after Tasks 2-5 have removed every reference.
- [ ] **Step 1: Verify zero remaining references across the whole workspace**
Run: `grep -rln "seaorm::common::settings\|seaorm::common::app_config\|seaorm::common::memory\b\|seaorm::common::edit_log" crates --include='*.rs'`
Expected: no output. (If anything other than the `mod.rs` declaration itself shows up, stop and investigate before deleting — it means a call site was missed in Tasks 2-5.)
- [ ] **Step 2: Delete the files**
```bash
git rm crates/zesdex-entities/src/seaorm/common/settings.rs
git rm crates/zesdex-entities/src/seaorm/common/app_config.rs
git rm crates/zesdex-entities/src/seaorm/common/memory.rs
git rm crates/zesdex-entities/src/seaorm/common/edit_log.rs
```
- [ ] **Step 3: Remove their module declarations**
In `crates/zesdex-entities/src/seaorm/common/mod.rs`, remove the corresponding `pub mod settings;`, `pub mod app_config;`, `pub mod memory;`, `pub mod edit_log;` lines (check first: `cat crates/zesdex-entities/src/seaorm/common/mod.rs`).
- [ ] **Step 4: Build the whole workspace**
Run: `cargo build --workspace`
Expected: no errors.
- [ ] **Step 5: Run the full test suite and clippy**
Run: `cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`
Expected: all pass, no new warnings.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "chore: hapus entitas settings/app_config/memory/edit_log lama di zesdex-entities yang sudah digantikan zesdex-cms"
```
@@ -0,0 +1,373 @@
# Convention Cleanup + Documentation Repair Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bring the codebase into compliance with `CLAUDE.md`'s own stated rules that the audit found violated — 110 `#[allow(...)]` lint-bypass attributes (10 of them silencing `dead_code`, which the workspace `Cargo.toml` explicitly `deny`s), a custom error type where only `anyhow` is supposed to be used, small doc-comment gaps — and repair the five `docs/CODEMAPS/*.md` files plus `CLAUDE.md` itself, which reference pre-workspace-migration paths that no longer exist.
**Architecture:** No structural changes to running code beyond what's needed to satisfy the lints without suppressing them. Documentation tasks are pure text corrections against the now-accurate `crates/` layout (this plan should run **after** the other four plans in this series, since they change many of the exact file paths the docs need to describe correctly).
**Tech Stack:** Rust, Markdown.
## Global Constraints
- No **new** `#[allow(...)]` may be introduced by this plan's own changes.
- Every dead-code removal must be verified by letting the compiler/clippy confirm the item has zero remaining callers — never delete on assumption.
- Tests are inline `#[cfg(test)] mod tests`.
- Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` after each task.
- **Run this plan last**, after `2026-07-16-security-quickfixes.md`, `2026-07-16-oauth-session-iam-wiring.md`, `2026-07-16-cms-settings-appconfig-memory-editlog-wiring.md`, `2026-07-16-cms-conversation-blob-wiring.md`, and `2026-07-16-middleware-axum-server.md` — the documentation tasks (Task 6) describe the *end state* of all five, and several files this plan touches for lint cleanup (`app/runtime/actions/mod.rs`, `app/runtime/context/*.rs`) are also touched by those plans.
---
### Task 1: Delete the unused custom `Error` type in `zesdex-utils`
**Context:** `crates/zesdex-utils/src/error.rs` defines a hand-rolled `pub enum Error` + `impl std::error::Error` + a `Result<T>` alias, directly contradicting CLAUDE.md's "anyhow::Result and anyhow::bail! throughout... No custom error types" rule. Confirmed via workspace-wide grep: **zero call sites reference it outside the file itself** — it's simply dead code, not something anything depends on. `thiserror` is declared as a `zesdex-utils` dependency but never imported anywhere in the crate Line truncated
**Files:**
- Delete: `crates/zesdex-utils/src/error.rs`
- Modify: `crates/zesdex-utils/src/lib.rs` (remove `pub mod error;`)
- Modify: `crates/zesdex-utils/Cargo.toml` (remove the unused `thiserror` dependency)
**Interfaces:** none — pure deletion.
- [ ] **Step 1: Verify zero remaining references**
Run: `grep -rln "zesdex_utils::error\|zesdex_utils::Error\|utils::error::" crates --include='*.rs'`
Expected: only `crates/zesdex-utils/src/error.rs` itself (or no output once the file is deleted).
- [ ] **Step 2: Delete the file**
```bash
git rm crates/zesdex-utils/src/error.rs
```
- [ ] **Step 3: Remove the module declaration**
In `crates/zesdex-utils/src/lib.rs`, remove:
```rust
pub mod error;
```
- [ ] **Step 4: Remove the unused `thiserror` dependency**
In `crates/zesdex-utils/Cargo.toml`, remove:
```toml
thiserror = { workspace = true }
```
- [ ] **Step 5: Build and test**
Run: `cargo build --workspace && cargo test -p zesdex-utils`
Expected: no errors.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "chore(utils): hapus custom Error type yang tidak dipakai (melanggar aturan anyhow-only)"
```
---
### Task 2: Resolve the 10 `dead_code` allow-bypasses
**Context:** These directly contradict the workspace's own `dead_code = "deny"` lint. For each, remove the `#[allow(dead_code)]`/`#![allow(dead_code)]`, run the compiler, and act on its verdict: if genuinely unused, delete; if actually reachable through a path the lint can't see (e.g. only used in `#[cfg(test)]` or behind a feature), wire it into real production code instead of re-suppressing.
**Files:**
- `crates/zesdex-backend/src/app/runtime/context/dedup.rs:1`
- `crates/zesdex-backend/src/app/runtime/context/squash.rs:1`
- `crates/zesdex-backend/src/app/runtime/context/window.rs:1`
- `crates/zesdex-backend/src/app/runtime/context/tokens.rs:32`
- `crates/zesdex-backend/src/app/subagent/spawn.rs:44,51`
- `crates/zesdex-backend/src/app/state/misc.rs:409`
- `crates/zesdex-backend/src/model/agent_def/{global.rs,builtin.rs,session.rs}:1`
**Interfaces:** varies per site — resolved during the investigation step, not fixed in advance (this is a "read what the compiler says, then act" task, not a hand-wave — see Step 1 of each site).
- [ ] **Step 1: `dedup.rs`, `squash.rs`, `window.rs` (module-level)**
Remove the `#![allow(dead_code)]` line from each of the three files. Run: `cargo build -p zesdex-backend 2>&1 | grep -A3 "never used"`
For each item the compiler flags as unused: check whether it's covered by a test in the same file's `#[cfg(test)] mod tests` (a test-only user doesn't count as a real caller and doesn't justify keeping the item) — if the item has zero non-test callers, delete it; if grepping the item's name elsewhere in `crates/zesdex-backend/src` (outside the file and outside `#[cfg(test)]` blocks) turns up a real caller the compiler somehow didn't connect (e.g. it's `pub` and meant for a different module thLine truncated
- [ ] **Step 2: `tokens.rs:32` (`count_message_tokens`)**
Read the function and its context: `grep -n -B5 -A15 "fn count_message_tokens" crates/zesdex-backend/src/app/runtime/context/tokens.rs`
Remove `#[allow(dead_code)]`. Run: `cargo build -p zesdex-backend 2>&1 | grep -A3 "count_message_tokens"`. If genuinely unused, delete the function (and any now-orphaned helper it alone called). If it looks like it *should* be called from the context-window-shaping logic in the same module (a token-counting function not being used by the token-budget code would itself be a functional gap worth flagging, not just a lint issue) — check `window.rs`'s `resolve()` and any `shaping`/`dedup` call siLine truncated
- [ ] **Step 3: `spawn.rs:44,51` (`with_max_steps`, `with_temperature` builder methods)**
Read the full builder struct: `grep -n -B20 "fn with_max_steps" crates/zesdex-backend/src/app/subagent/spawn.rs`
Remove both `#[allow(dead_code)]` lines. Run: `cargo build -p zesdex-backend 2>&1 | grep -A3 "with_max_steps\|with_temperature"`.
These configure per-agent `max_steps`/`temperature` on a subagent-spawn builder — check every call site that constructs this builder (`grep -rn "AgentSpawnBuilder\|::new()" crates/zesdex-backend/src/app/subagent/` — use the builder's actual type name found in Step 3's read) to see whether any caller *should* be setting these (e.g. does `hive_mind.rs`'s node-spawning code hardcode a default that should instead come from `Settings`/`NodeDirective` and isn't?). If a real caller needs them, wirLine truncated
- [ ] **Step 4: `state/misc.rs:409` (`api_context_length` field)**
Read the surrounding struct: `grep -n -B15 -A5 "api_context_length" crates/zesdex-backend/src/app/state/misc.rs`
Remove `#[allow(dead_code)]`. Run: `cargo build -p zesdex-backend 2>&1 | grep -A3 "api_context_length"`. Check whether the status bar (`view/status.rs`) or connectivity-check code (`spawn_api_connectivity_check` in `actions/mod.rs`) should be displaying/using the model's context length but currently isn't — if so, wire it in; if the field was superseded by `app_config.model_roles[...].context_window` (per the CMS wiring plan) and is now genuinely redundant, delete the field.
- [ ] **Step 5: `model/agent_def/{global.rs,builtin.rs,session.rs}` (module-level)**
Same procedure as Step 1: remove each `#![allow(dead_code)]`, build, and either delete unused items or wire in real callers based on what the compiler reports.
- [ ] **Step 6: Full workspace build and test after all 10 sites are resolved**
Run: `cargo build --workspace && cargo test --workspace`
Expected: no errors, no `dead_code` warnings anywhere (the workspace `deny` will turn any remaining one into a hard build failure, which is the actual verification that every site was genuinely resolved).
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "fix: hapus 10 allow(dead_code) - hapus kode mati atau sambungkan ke pemanggil nyata"
```
---
### Task 3: Resolve the 7 item-level clippy allows
**Files:**
- `crates/zesdex-backend/src/app/review/mod.rs:371` (`clippy::unnecessary_debug_formatting`)
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs:1532` (`clippy::too_many_arguments`)
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs:99,912` (`clippy::too_many_lines`, x2)
- `crates/zesdex-backend/src/app/subagent/engine.rs:326` (`clippy::too_many_lines`)
- `crates/zesdex-backend/src/view/markdown.rs:62` (`clippy::too_many_lines`)
- `crates/zesdex-backend/src/view/mod.rs:112` (`clippy::too_many_lines`)
**Interfaces:** none shared across sites — each is an independent, local fix.
- [ ] **Step 1: `clippy::unnecessary_debug_formatting` (easiest — do first)**
Read the flagged line: `grep -n -B3 -A3 "unnecessary_debug_formatting" crates/zesdex-backend/src/app/review/mod.rs`
Remove the `#[allow(clippy::unnecessary_debug_formatting)]` line. Run: `cargo clippy -p zesdex-backend 2>&1 | grep -A5 "unnecessary_debug_formatting"` to see the exact suggestion (clippy always proposes the fix inline — typically replacing a `format!("{:?}", x)` with `x.to_string()` or a `Display` impl call). Apply the suggested fix exactly.
- [ ] **Step 2: `clippy::too_many_arguments` on `actions/mod.rs:1532`**
Read the flagged function's full signature: `grep -n -B2 -A15 "clippy::too_many_arguments" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
Bundle the excess parameters into a purpose-named struct (the standard fix for this lint). For example, if the function is `fn foo(a: X, b: Y, c: Z, d: W, ...) -> R`, introduce:
```rust
struct FooParams {
a: X,
b: Y,
c: Z,
d: W,
// ...
}
```
and change the signature to `fn foo(params: FooParams) -> R`, updating the function body to read `params.a`/`params.b`/etc., and updating the single call site to construct `FooParams { a, b, c, d, ... }`. (Exact field names/types depend on the actual signature found in this step's read — do not guess, use the literal parameter list.)
- [ ] **Step 3: `clippy::too_many_lines` — `actions/mod.rs:99` and `:912` (`run_agent_turn`)**
Read the full function: `grep -n -A 250 "^fn run_agent_turn" crates/zesdex-backend/src/app/runtime/actions/mod.rs | head -260`
This is the core per-turn agent loop — do not split it mechanically by line count; split along its own documented phase boundaries (the function's doc comment already describes them: "build system prompt → shape messages → call `chat_with_tools_streaming` → handle tool calls or unwrap final message → check unfinished todos → finalize"). Extract each phase that doesn't need to mutate more than 2-3 local variables into its own well-named private function, threading only what each phasLine truncated
Run after each extraction: `cargo build -p zesdex-backend && cargo test -p zesdex-backend`
Once the function is under clippy's threshold, remove the `#[allow(clippy::too_many_lines)]` at both flagged lines (99 and 912 — confirm both are on `run_agent_turn` or its immediate helper via the Step 1 grep; if they're on two different functions, repeat this decomposition process for each independently).
- [ ] **Step 4: `clippy::too_many_lines` — `app/subagent/engine.rs:326`**
Read the flagged function in full: `grep -n -B2 -A 200 "clippy::too_many_lines" crates/zesdex-backend/src/app/subagent/engine.rs | head -210`
Apply the same phase-based extraction approach as Step 3, scaled to this function's actual structure (read it first — do not assume it mirrors `run_agent_turn`'s shape).
- [ ] **Step 5: `clippy::too_many_lines` — `view/markdown.rs:62` and `view/mod.rs:112`**
Read both flagged functions in full first (`grep -n -A 150 "clippy::too_many_lines" crates/zesdex-backend/src/view/markdown.rs` and the equivalent for `view/mod.rs`). These are rendering functions — split along rendering sub-sections (e.g. one function per overlay/pane already rendered inline in a big `match`), extracting each `match` arm's body over some line-count threshold into its own `fn render_<thing>(f: &mut Frame, area: Rect, state: &AppStateRest)`-shaped helper, matching the existingLine truncated
- [ ] **Step 6: Full workspace verification**
Run: `cargo build --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`
Expected: all pass with zero `too_many_lines`/`too_many_arguments`/`unnecessary_debug_formatting` warnings and no remaining `#[allow]` for any of them.
- [ ] **Step 7: Commit each function's decomposition separately as you go (already instructed inline above) — final wrap-up commit if anything remains uncommitted**
```bash
git add -A
git commit -m "refactor: pecah fungsi yang melanggar clippy::too_many_lines/too_many_arguments, hapus allow-nya"
```
---
### Task 4: Reduce the 93 module-level cast-quad allows
**Context:** `#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]` appears at the top of 93 files, evidently copy-pasted as workspace-wide boilerplate rather than justified per-file. This is the largest item in this plan by file count and — because it's the same mechanical recipe repeated 93 times — is best executed via `superpowers:subagent-driven-development` dispatching one subagent per file (or small batch of relatLine truncated
**Files:** all 93 listed in the audit's inventory (re-derive the authoritative current list before starting, since Tasks 1-3 and the other four plans in this series may have deleted or renamed some of them):
Run: `grep -rln "cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap" crates --include='*.rs'`
**Interfaces:** none shared — each file's fix is independent and self-contained.
- [ ] **Step 1: Worked example — pick one small, representative file first**
Read a small file from the list, e.g. `crates/zesdex-backend/src/app/mode/effort.rs` (confirm it's still in the current list from this task's Step-1 grep before using it as the example). Remove its `#![allow(clippy::cast_*...)]` line. Run:
Run: `cargo clippy -p zesdex-backend -- -D warnings 2>&1 | grep -B2 -A8 "effort.rs"`
For each flagged cast, apply the narrowest correct fix:
- `x as u32` where `x: usize` and the value is a count/length that can't realistically exceed `u32::MAX` → `u32::try_from(x).unwrap_or(u32::MAX)` (saturating, since these are almost always display/telemetry values where saturating is safe) or, if the call site already returns `Result`, `u32::try_from(x)?`.
- `x as i64` where `x: u64` timestamp (milliseconds since epoch) → these are safe until year 292471247, so `TryFrom` is technically correct but arguably pedantic; use `i64::try_from(x).unwrap_or(i64::MAX)` for consistency with the rule above rather than special-casing "this one's fine."
- `x as f32`/`x as f64` (precision loss) on values already known to fit (e.g. small counters) → keep the cast but make it explicit and document why it's lossless in context: `#[expect(clippy::cast_precision_loss, reason = "...")]` is still a bypass and NOT allowed by CLAUDE.md — instead, if the value truly can't lose precision (e.g. casting a `u8` to `f32`), the lint won't even fire once the blanket module-level allow is removed, since clippy's precision-loss lint only fires above the pointLine truncated
Run: `cargo build -p zesdex-backend && cargo test -p zesdex-backend`
Expected: no errors, no new warnings for this file.
- [ ] **Step 2: Commit the worked example**
```bash
git add crates/zesdex-backend/src/app/mode/effort.rs
git commit -m "fix: hapus allow cast-quad di effort.rs, ganti cast lossy dengan try_from"
```
- [ ] **Step 3: Dispatch the remaining files via subagent-driven-development**
For the remaining files from Step 1's grep (minus the one just fixed), use `superpowers:subagent-driven-development` with one task per file (or per small group of 3-5 files within the same module, where that reads more naturally), each task instructing: "remove the `#![allow(clippy::cast_*)]` header from `<file>`, run `cargo clippy -p <crate> -- -D warnings` scoped to that file, and fix every flagged cast using the recipe demonstrated in `2026-07-16-convention-cleanup-docs.md` Task 4 Step 1 (prLine truncated
- [ ] **Step 4: Final workspace-wide verification**
Run: `grep -rln "cast_possible_truncation, clippy::cast_sign_loss" crates --include='*.rs'`
Expected: no output (or, if a small number of files remain and are judged genuinely fine to leave as a future increment, that's a call for whoever is running this plan to make explicitly and document — not silently left as-is).
Run: `cargo build --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`
Expected: all pass.
---
### Task 5: Fill the remaining doc-comment gaps and remove dead scaffolding
**Files:**
- Modify: `crates/zesdex-backend/src/view/mod.rs` (lines 10-15 — `pub mod chat/markdown/sidebar/status/theme/workflow`, 5 of 7 missing doc comments)
- Modify: `crates/zesdex-backend/src/app/state/misc.rs` (line 340 `pub fn submit`, and the second gap the audit found around line 438)
- Delete: `/mnt/code/zesdex/tests/` (confirmed empty and untracked — safe to remove; re-verify emptiness before deleting since time has passed since the original audit)
**Interfaces:** none — doc comments and a directory deletion, no behavior change.
- [ ] **Step 1: Add doc comments to `view/mod.rs`'s module declarations**
Read the current lines: `grep -n -B1 "^pub mod" crates/zesdex-backend/src/view/mod.rs`
For each of `chat`, `markdown`, `sidebar`, `status`, `theme`, `workflow` that lacks a one-line doc comment above it, add one describing what that view submodule renders — e.g.:
```rust
/// Chat transcript pane: renders the scrollback of user/assistant/tool messages.
pub mod chat;
/// Markdown-to-styled-text rendering for assistant message content.
pub mod markdown;
/// Session sidebar: file tree / workspace navigation pane.
pub mod sidebar;
/// Status bar: provider/model, token usage, connectivity indicator.
pub mod status;
/// Color theme definitions for the TUI.
pub mod theme;
/// Hive-mind workflow panel: live node progress display.
pub mod workflow;
```
(Read each module's actual top-of-file doc comment first — `head -5 crates/zesdex-backend/src/view/{chat,markdown,sidebar,status,theme,workflow}.rs` — and base the one-liner on what that file's own doc comment says, rather than guessing, so the two stay consistent.)
- [ ] **Step 2: Add doc comments to the two flagged functions in `state/misc.rs`**
Read both: `grep -n -B2 -A8 "pub fn submit" crates/zesdex-backend/src/app/state/misc.rs` and the second flagged line (re-locate it — the original audit found it around line 438, but Task 2's dead-code cleanup on this same file may have shifted line numbers; search for the nearest undocumented `pub fn` instead of trusting the stale line number).
Add a doc comment to `submit` describing what it does (flow: clone the input buffer as the result, push to history if non-empty and not a duplicate of the last entry, persist history to disk if a history file is configured; return the submitted text) following CLAUDE.md's What/Flow/Why/Return structure, and do the same for the second flagged function once located.
- [ ] **Step 3: Remove the empty `tests/` directory**
Run: `find /mnt/code/zesdex/tests -mindepth 1` to confirm it's still empty.
Expected: no output.
If confirmed empty:
```bash
rmdir /mnt/code/zesdex/tests
```
(Use `rmdir`, not `rm -rf` — it only succeeds if the directory is genuinely empty, which is the safety property we want here.)
- [ ] **Step 4: Build and verify**
Run: `cargo build --workspace && cargo doc --workspace --no-deps 2>&1 | grep -i warn`
Expected: no new warnings from `cargo doc` (missing-docs isn't a workspace lint here, but this is a quick sanity pass).
- [ ] **Step 5: Commit**
```bash
git add crates/zesdex-backend/src/view/mod.rs crates/zesdex-backend/src/app/state/misc.rs
git rm -r --cached tests 2>/dev/null || true
git commit -m "docs: lengkapi doc comment view/mod.rs & state/misc.rs, hapus dir tests/ kosong"
```
---
### Task 6: Repair `docs/CODEMAPS/*.md` and `CLAUDE.md` to match the post-migration + post-wiring layout
**Context:** All five CODEMAPS files and CLAUDE.md itself reference pre-workspace-migration paths (`src/main.rs` instead of `crates/zesdex-backend/src/main.rs`, etc.), and `dependencies.md` describes a monolithic-crate dependency list that predates the workspace split entirely. Run this task **last**, after the other four plans in this series have landed, since many paths this task documents (OAuth location, Settings/AppConfig/Memory/EditLog/Conversation persistence, the new HTTP bridge) only eLine truncated
**Files:**
- Modify: `docs/CODEMAPS/architecture.md`
- Modify: `docs/CODEMAPS/backend.md`
- Modify: `docs/CODEMAPS/frontend.md`
- Modify: `docs/CODEMAPS/data.md`
- Modify: `docs/CODEMAPS/dependencies.md`
- Modify: `/mnt/code/zesdex/CLAUDE.md`
**Interfaces:** none — documentation only.
- [ ] **Step 1: Regenerate the authoritative file-path list**
Run: `find crates -name '*.rs' -not -path '*/target/*' | sort > /tmp/current-rust-files.txt` and keep this alongside the docs while editing, so every path cited is checked against a real file, not memory.
- [ ] **Step 2: Fix `architecture.md`**
For every code path mentioned (the Key Files table and inline references), prepend the correct crate prefix — e.g. `src/main.rs` → `crates/zesdex-backend/src/main.rs`, `src/app/harness.rs` → `crates/zesdex-backend/src/app/harness.rs`, and so on for every row. Cross-check each against `/tmp/current-rust-files.txt` from Step 1 before writing it. Update the ASCII system-layout diagram's "Tool/Subagents/Workflow" box if the OAuth rewiring (from `2026-07-16-oauth-session-iam-wiring.md`) or theLine truncated
- [ ] **Step 3: Fix `backend.md`**
Correct every path (`dto/provider/` → `crates/zesdex-dto/src/provider/`, `service/oauth/` → now `crates/zesdex-iam/src/{application/oauth_service.rs,infrastructure/oauth_loopback.rs}` per the OAuth rewiring plan, `src/ipc/*.rs` → `crates/zesdex-ipc/src/{protocol,conn,client,server}.rs`, etc.). Add a new subsection documenting the OAuth/session/CMS wiring: which crate now owns each concern (`zesdex-iam` for OAuth+session, `zesdex-cms` for Settings/AppConfig/Memory/EditLog/Conversation, `zeLine truncated
- [ ] **Step 4: Fix `frontend.md`**
Correct every `main.rs`/`controller/input.rs`/`view/*.rs`/`app/mode/*.rs` reference to include the `crates/zesdex-backend/src/` prefix.
- [ ] **Step 5: Fix `data.md`**
Correct `src/app/state/rest.rs` → `crates/zesdex-backend/src/app/state/rest.rs`. Replace the section describing `src/model/{settings,app_config,memory,edit_log}.rs` (all deleted by the CMS wiring plans) with a description of `zesdex-cms`'s repository-based persistence (`JsonSettingsRepository`, `JsonAppConfigRepository`, `MarkdownMemoryRepository`, `JsonlEditLogRepository`, `JsonConversationRepository`, `FileRewindBlobRepository`) and where each writes on disk. Replace the `msglog` SQLite desLine truncated
- [ ] **Step 6: Rewrite `dependencies.md`**
Replace the header claim ("23 Rust crates" per CLAUDE.md / "30+ direct" per this file's own header — pick neither, state the actual count) with an accurate summary: list all 9 internal workspace crates (`zesdex-entities`, `zesdex-utils`, `zesdex-dto`, `zesdex-ipc`, `zesdex-iam`, `zesdex-cms`, `zesdex-middleware`, `zesdex-libs`, `zesdex-backend`) with a one-line purpose each, then the external dependency list — regenerate this list from the actual `[workspace.dependencies]` table in the rootLine truncated
Run: `grep -A100 "\[workspace.dependencies\]" Cargo.toml`
Explicitly call out the dependencies added by the workspace migration that the current doc omits entirely: `axum`, `tower`, `tower-http`, `argon2`, `jsonwebtoken`, `thiserror` (note: `thiserror` may be removed from the workspace entirely by Task 1 of this plan if `zesdex-utils` was its only consumer — check with `grep -rln "thiserror" crates --include='*.rs' crates/*/Cargo.toml` before listing it as a current dependency).
- [ ] **Step 7: Fix `CLAUDE.md`**
Update every path in the "Key Files"-equivalent references (`src/main.rs`, `src/app/harness.rs`, `src/app/subagent/division.rs`, `src/app/workflow/hive_mind.rs`, `src/tool/workflow.rs`, `src/app/workflow/docs.rs`, `src/view/workflow.rs`, `src/app/subagent/auto.rs`) to their `crates/zesdex-backend/src/...` equivalents. Update the "Shell safety" line per `2026-07-16-security-quickfixes.md` Task 1 Step 4 if that plan hasn't already been applied. Update the "No custom error types" line's context ifLine truncated
- [ ] **Step 8: Verify every path cited resolves to a real file**
Run a small verification script for each doc — for every backtick-quoted path matching `src/` or `crates/`, confirm it exists:
```bash
for f in docs/CODEMAPS/*.md CLAUDE.md; do
grep -oE '`[a-zA-Z0-9_/.-]+\.rs`' "$f" | tr -d '`' | while read -r path; do
[ -f "$path" ] || echo "MISSING in $f: $path"
done
done
```
Expected: no `MISSING` lines. Fix any that appear.
- [ ] **Step 9: Commit**
```bash
git add docs/CODEMAPS CLAUDE.md
git commit -m "docs: perbaiki path stale di CODEMAPS dan CLAUDE.md pasca migrasi workspace + wiring zesdex-iam/cms/middleware"
```
@@ -0,0 +1,523 @@
# Middleware Axum Server Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give the previously-orphaned `zesdex-middleware` crate (`SessionAuthLayer`, `default_cors_layer`, `RateLimitLayer`) a genuine integration point by adding an optional HTTP transport for the existing daemon, alongside (not replacing) the current Unix-socket transport.
**Important scope note — read before implementing:** unlike the OAuth/session/CMS wiring plans, there is **no existing HTTP server to fix or complete** — research confirmed zero axum usage anywhere in `zesdex-backend` and no design doc describing what one should do. This plan is therefore new-feature work, deliberately scoped as narrowly as possible: it exposes the *exact same* `ClientRequest`/`DaemonFrame` protocol the Unix-socket daemon already speaks, over HTTP, gated by the three middleLine truncated
**Architecture:** Extract the daemon's per-request handling logic (`handle_daemon_client`'s match-on-`ClientRequest` body plus `send_daemon_update`) into transport-agnostic functions shared by both the existing Unix-socket loop and a new axum route. The state-owning thread gains an `mpsc` channel; the axum handler sends `(ClientRequest, oneshot::Sender<Vec<DaemonFrame>>)` and awaits the reply. `--http-port <PORT>` is a new opt-in CLI flag on `--daemon` — when absent, behavior is byte-for-byteLine truncated
**Tech Stack:** Rust, `axum`, `tokio` (already workspace deps), `zesdex-middleware`.
## Global Constraints
- The Unix-socket transport's behavior must be provably unchanged — the refactor in Task 1 extracts logic without altering it, verified by the existing (or newly added, if none exist) daemon tests passing identically before and after.
- No new `#[allow(...)]` attributes.
- Tests are inline `#[cfg(test)] mod tests`.
- Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` before each commit.
- The HTTP transport is opt-in (`--http-port`) and OFF by default — it must not change any existing invocation's behavior.
---
### Task 1: Extract transport-agnostic request handling from `handle_daemon_client`
**Files:**
- Modify: `crates/zesdex-backend/src/main.rs` (functions `handle_daemon_client` ~line 336, `send_daemon_update` ~line 207)
**Interfaces:**
- Produces: `fn build_state_update_frame(state: &AppStateRest) -> ipc::protocol::DaemonFrame` (pure builder, extracted from `send_daemon_update`) and `fn process_client_request(state: &mut AppStateRest, req: ipc::protocol::ClientRequest) -> (bool, Vec<ipc::protocol::DaemonFrame>)` (pure state-mutation + frame-collection, extracted from `handle_daemon_client`'s match body) — both consumed by Task 3's axum handler and Task 2's refactored Unix-socket loop.
- [ ] **Step 1: Extract `build_state_update_frame`**
In `crates/zesdex-backend/src/main.rs`, split `send_daemon_update` (current body at line ~207-238) into a pure builder plus a thin I/O wrapper:
```rust
/// Flatten the daemon's `AppStateRest` into a `StatePayload` wrapped in a
/// `DaemonFrame::StateUpdate` — the pure, transport-agnostic half of what
/// was previously `send_daemon_update`.
///
/// Why: the client never shares memory with the daemon, so every action
/// on the daemon side is followed by a full state push rather than a diff.
fn build_state_update_frame(state: &app::state::rest::AppStateRest) -> ipc::protocol::DaemonFrame {
use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload};
let messages: Vec<MessageEntry> = state.transcript_cache.messages.iter().map(|m| {
MessageEntry {
role: format!("{:?}", m.role),
content: m.content.clone(),
timestamp: m.timestamp,
}
}).collect();
let toasts: Vec<ToastEntry> = state.misc.toasts.iter().map(|t| {
ToastEntry {
kind: format!("{:?}", t.kind),
message: t.message.clone(),
created_at: t.created_at,
lifetime_ms: t.lifetime_ms,
}
}).collect();
let overlay = if state.misc.overlay.is_active() {
Some(format!("{:?}", state.misc.overlay))
} else {
None
};
// Keep every remaining `StatePayload` field exactly as the original
// `send_daemon_update` built it (input buffer/cursor, etc.) — copy the
// rest of the struct-literal body unchanged from the pre-refactor code.
DaemonFrame::StateUpdate(Box::new(StatePayload {
session_id: state.session_id.clone(),
messages,
toasts,
overlay,
// ...(remaining fields copied verbatim from the original function)
}))
}
/// Send a `DaemonFrame::StateUpdate` to an attached Unix-socket client.
fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> {
conn.send(&build_state_update_frame(state))
}
```
(Read the full original `send_daemon_update` body first — `sed`/`grep -n -A 45 "fn send_daemon_update" crates/zesdex-backend/src/main.rs` — and carry over every `StatePayload` field exactly; the excerpt above only shows the fields already visible in this plan's earlier research, do not drop any field the original builds.)
- [ ] **Step 2: Extract `process_client_request`**
Replace `handle_daemon_client`'s inner `match req { ... }` block with a new standalone function that returns frames instead of writing to a `Connection`:
```rust
/// Apply one `ClientRequest` to `state` and collect the `DaemonFrame`(s) it
/// produces — the pure, transport-agnostic half of what was previously
/// inlined in `handle_daemon_client`'s read loop.
///
/// Return: `(keep_running, frames)` — `keep_running` is `false` only for
/// `ClientRequest::Close`; `frames` always ends with a `StateUpdate` frame,
/// preceded by a `ClipboardCopy` frame if a copy was pending.
fn process_client_request(
state: &mut app::state::rest::AppStateRest,
req: ipc::protocol::ClientRequest,
) -> (bool, Vec<ipc::protocol::DaemonFrame>) {
use app::runtime::actions::{Action, apply_action};
use ipc::protocol::ClientRequest;
let mut running = true;
match req {
ClientRequest::Tick => {
apply_action(state, Action::Tick);
}
ClientRequest::KeyPress { key, ctrl, alt, shift } => {
let mut modifiers = crossterm::event::KeyModifiers::NONE;
if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; }
if alt { modifiers |= crossterm::event::KeyModifiers::ALT; }
if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; }
let key_event = crossterm::event::KeyEvent::new(key_action_to_code(&key), modifiers);
let actions = controller::input::handle_key(key_event, state);
for action in actions {
apply_action(state, action);
}
apply_action(state, Action::Tick);
}
ClientRequest::Submit(text) => {
state.input.buffer = text;
let enter_event = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE);
let actions = controller::input::handle_key(enter_event, state);
for action in actions {
apply_action(state, action);
}
apply_action(state, Action::Tick);
}
ClientRequest::Paste(text) => {
state.input.buffer.insert_str(state.input.cursor, &text);
state.input.cursor += text.len();
state.dirty = true;
apply_action(state, Action::Tick);
}
ClientRequest::Resize(w, h) => {
apply_action(state, Action::Resize(w, h));
apply_action(state, Action::Tick);
}
ClientRequest::ScrollUp => {
apply_action(state, Action::ScrollUp);
apply_action(state, Action::Tick);
}
ClientRequest::ScrollDown => {
apply_action(state, Action::ScrollDown);
apply_action(state, Action::Tick);
}
ClientRequest::Close => {
running = false;
}
}
let mut frames = Vec::new();
if let Some(text) = state.misc.pending_clipboard_copy.take() {
frames.push(ipc::protocol::DaemonFrame::ClipboardCopy(text));
}
frames.push(build_state_update_frame(state));
(running, frames)
}
```
(Every match arm's body is copied verbatim from the pre-refactor `handle_daemon_client` — no logic changes, only relocation.)
- [ ] **Step 3: Rewrite `handle_daemon_client` as a thin wrapper**
```rust
fn handle_daemon_client(
mut conn: ipc::conn::Connection,
state: &mut app::state::rest::AppStateRest,
) -> Result<()> {
use ipc::protocol::ClientRequest;
loop {
match conn.receive::<ClientRequest>()? {
Some(req) => {
let (running, frames) = process_client_request(state, req);
for frame in frames {
conn.send(&frame)?;
}
if !running {
break;
}
}
None => break,
}
}
Ok(())
}
```
(Note: the original sent `ClipboardCopy` then a `StateUpdate` as two separate `conn.send` calls per request — the `for frame in frames` loop preserves that exact ordering since `process_client_request` pushes them in the same order.)
- [ ] **Step 4: Build and test**
Run: `cargo build -p zesdex-backend && cargo test -p zesdex-backend`
Expected: no errors, all existing tests pass.
- [ ] **Step 5: Manual regression check on the Unix-socket path**
Run the daemon + attach flow manually (`cargo run -p zesdex-backend -- --daemon` in one terminal, `cargo run -p zesdex-backend -- --attach <session-id>` in another) and confirm keypresses, submit, resize, scroll, and clean close all behave exactly as before this refactor.
- [ ] **Step 6: Commit**
```bash
git add crates/zesdex-backend/src/main.rs
git commit -m "refactor(backend): ekstrak process_client_request/build_state_update_frame agar transport-agnostic"
```
---
### Task 2: Add an `mpsc`-bridged worker so the state owner can serve two transports
**Files:**
- Modify: `crates/zesdex-backend/src/main.rs` (`run_daemon`, ~line 427)
**Interfaces:**
- Produces: `run_daemon` spawns the existing Unix-socket accept loop on the calling thread as today, but if `--http-port` is set (Task 4), a second axum server (Task 3) sends requests into the same state via a shared `std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>` that the daemon's main loop polls alongside the Unix-socket `accept()`.
- [ ] **Step 1: Add a request channel to the daemon loop**
Read the current `run_daemon` in full first: `grep -n -A 60 "fn run_daemon" crates/zesdex-backend/src/main.rs`
Introduce, near the top of `run_daemon` (after `state` is constructed, before the accept loop):
```rust
// Bridge channel: lets an (optional) HTTP transport submit
// `ClientRequest`s into this thread's owned `AppStateRest`, exactly as
// the Unix-socket accept loop does. `bridge_rx` is polled with a
// short timeout alongside `server.accept()` so both transports can
// make progress on the single thread that owns `state`.
let (bridge_tx, bridge_rx) = std::sync::mpsc::channel::<(
ipc::protocol::ClientRequest,
std::sync::mpsc::Sender<Vec<ipc::protocol::DaemonFrame>>,
)>();
```
- [ ] **Step 2: Poll the bridge channel in the accept loop**
Locate the existing `loop { match server.accept() { ... } }` (or equivalent) in `run_daemon`. Since `UnixListener::accept()` blocks, switch it to non-blocking with a short poll interval so the bridge channel also gets serviced:
```rust
server.set_nonblocking(true)?; // confirm `IpcServer` exposes this — if not, add a thin `set_nonblocking` passthrough to `zesdex-ipc`'s `IpcServer` in this same task
loop {
// Drain any pending HTTP-bridged requests first.
while let Ok((req, reply_tx)) = bridge_rx.try_recv() {
let (_running, frames) = process_client_request(&mut state, req);
let _ = reply_tx.send(frames);
}
match server.accept() {
Ok(conn) => {
handle_daemon_client(conn, &mut state)?;
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(20));
}
Err(e) => {
eprintln!("daemon: accept error: {e}");
}
}
}
```
(If `IpcServer` doesn't currently expose `set_nonblocking`, add it to `crates/zesdex-ipc/src/server.rs` as a one-line passthrough to the underlying `UnixListener::set_nonblocking`, with a doc comment explaining why: enables polling the HTTP bridge channel on the same thread without blocking indefinitely on Unix-socket `accept()`.)
- [ ] **Step 3: Thread `bridge_tx` out to Task 3**
Have `run_daemon` pass a clone of `bridge_tx` to the HTTP-server-spawning code added in Task 4 (only reached when `--http-port` is set).
- [ ] **Step 4: Build**
Run: `cargo check -p zesdex-backend`
Expected: no errors (Task 4 hasn't added the HTTP server yet, so `bridge_tx` may show an "unused" warning until then — acceptable transiently within this plan's own task sequence, but must be resolved by the time Task 4 finishes; do not leave an `#[allow(dead_code)]` on it in the interim).
- [ ] **Step 5: Commit**
```bash
git add crates/zesdex-backend/src/main.rs crates/zesdex-ipc/src/server.rs
git commit -m "feat(backend): tambahkan channel jembatan mpsc di run_daemon untuk transport HTTP opsional"
```
---
### Task 3: Add the axum HTTP bridge endpoint using `zesdex-middleware`
**Files:**
- Create: `crates/zesdex-backend/src/ipc_http.rs`
- Modify: `crates/zesdex-backend/src/main.rs` (module declaration + call site)
- Modify: `crates/zesdex-backend/Cargo.toml` (confirm `axum`/`tokio` already present — they are, per workspace deps; no change needed, just verify with `grep -E "^axum|^tokio" crates/zesdex-backend/Cargo.toml`)
**Interfaces:**
- Consumes: `zesdex_middleware::auth::{SessionAuthLayer, SessionIdentity}`, `zesdex_middleware::cors::default_cors_layer`, `zesdex_middleware::rate_limit::{RateLimiter, RateLimitLayer}` (with `trust_proxy_headers: false` per the `2026-07-16-security-quickfixes.md` plan's Task 2), the `bridge_tx` sender from Task 2.
- Produces: `pub async fn serve_http_bridge(port: u16, store: zesdex_entities::seaorm::common::store::Store, bridge_tx: std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>) -> anyhow::Result<()>` — spawned as a tokio task by `run_daemon` when `--http-port` is set.
- [ ] **Step 1: Write the route handler**
Create `crates/zesdex-backend/src/ipc_http.rs`:
```rust
//! Optional HTTP transport for the daemon, bridging to the same
//! `ClientRequest`/`DaemonFrame` protocol the Unix-socket transport uses.
//!
//! Exists solely to give `zesdex-middleware`'s `SessionAuthLayer`,
//! `default_cors_layer`, and `RateLimitLayer` a real caller — it
//! deliberately does NOT introduce a new REST API surface; the one route
//! below is a thin bridge onto the pre-existing IPC protocol.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::routing::post;
use axum::Router;
use ipc::protocol::{ClientRequest, DaemonFrame};
type BridgeSender = std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>;
#[derive(Clone)]
struct HttpBridgeState {
bridge_tx: std::sync::Arc<std::sync::Mutex<BridgeSender>>,
}
/// Handle one bridged `ClientRequest`, blocking (on a blocking-safe tokio
/// task) until the daemon's state-owning thread replies.
///
/// Flow: build a one-shot `std::sync::mpsc` reply channel → send
/// `(req, reply_tx)` into the daemon's bridge channel → block on
/// `reply_rx.recv()` via `tokio::task::spawn_blocking` (since the daemon
/// thread's reply is synchronous, not a future) → return the frames as
/// JSON.
///
/// Return: `200` with the frame list on success, `500` if the daemon
/// thread is gone (channel send/receive failed) or the bridge send failed.
async fn handle_request(
State(state): State<HttpBridgeState>,
Json(req): Json<ClientRequest>,
) -> impl IntoResponse {
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
let send_result = state
.bridge_tx
.lock()
.map_err(|_| ())
.and_then(|tx| tx.send((req, reply_tx)).map_err(|_| ()));
if send_result.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(Vec::<DaemonFrame>::new()));
}
let frames = tokio::task::spawn_blocking(move || reply_rx.recv().unwrap_or_default())
.await
.unwrap_or_default();
(StatusCode::OK, Json(frames))
}
/// Serve the HTTP bridge on `127.0.0.1:<port>`, gated by session auth,
/// CORS, and rate limiting from `zesdex-middleware`.
///
/// Why 127.0.0.1 only: this bridge is meant for local attach clients that
/// prefer HTTP over a Unix socket (e.g. a browser-based frontend on the
/// same machine), not a remote API — it is never exposed beyond loopback.
///
/// Return: `Err` if the port can't be bound; otherwise runs until the
/// process exits (mirrors the Unix-socket daemon's lifetime).
pub async fn serve_http_bridge(
port: u16,
store: zesdex_entities::seaorm::common::store::Store,
bridge_tx: BridgeSender,
) -> anyhow::Result<()> {
let http_state = HttpBridgeState {
bridge_tx: std::sync::Arc::new(std::sync::Mutex::new(bridge_tx)),
};
let rate_limiter = zesdex_middleware::rate_limit::RateLimiter::new(/* existing constructor args, e.g. window/limit — read crates/zesdex-middleware/src/rate_limit.rs's `RateLimiter::new` signature first */);
let app = Router::new()
.route("/ipc/request", post(handle_request))
.layer(zesdex_middleware::auth::SessionAuthLayer::new(store))
.layer(zesdex_middleware::cors::default_cors_layer())
.layer(zesdex_middleware::rate_limit::RateLimitLayer::new(rate_limiter))
.with_state(http_state);
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("[http-bridge] listening on {addr}");
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn serve_http_bridge_rejects_requests_without_session_header() {
let store = zesdex_entities::seaorm::common::store::Store::new();
let (tx, _rx) = std::sync::mpsc::channel();
// Bind on port 0 equivalent isn't directly expressible via this
// function's fixed-port signature — for this test, spawn the
// server on an ephemeral high port and hit it with `reqwest`,
// asserting a 401 when `X-Session-Id` is absent. Pick a
// collision-unlikely test port derived from the process id to
// avoid flaky parallel-test port clashes:
let port = 20000 + (std::process::id() % 10000) as u16;
let server = tokio::spawn(serve_http_bridge(port, store, tx));
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let resp = reqwest::Client::new()
.post(format!("http://127.0.0.1:{port}/ipc/request"))
.json(&ClientRequest::Tick)
.send()
.await
.expect("request should reach the server");
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
server.abort();
}
}
```
(The `RateLimiter::new` call needs its actual constructor arguments — read `crates/zesdex-middleware/src/rate_limit.rs` first to fill these in precisely; after applying `2026-07-16-security-quickfixes.md`'s Task 2, prefer `RateLimiter::new(...)` — the safe, non-proxy-trusting constructor — over `with_proxy_trust`, since this bridge sits directly on loopback with no fronting proxy.)
- [ ] **Step 2: Register the module**
In `crates/zesdex-backend/src/main.rs`, add near the other `mod`/`use` declarations:
```rust
mod ipc_http;
```
- [ ] **Step 3: Run the test**
Run: `cargo test -p zesdex-backend serve_http_bridge_rejects -- --nocapture`
Expected: pass (needs `SessionAuthLayer` to actually reject unauthenticated requests — if the test fails because `SessionAuthLayer`'s validation logic doesn't match this expectation, read `crates/zesdex-middleware/src/auth.rs`'s `validate_session`/`SessionAuthMiddleware::call` in full and adjust the test to match its actual documented rejection behavior rather than changing the middleware itself, since that's pre-existing, previously-audited code out of this plan's scope).
- [ ] **Step 4: Commit**
```bash
git add crates/zesdex-backend/src/ipc_http.rs crates/zesdex-backend/src/main.rs
git commit -m "feat(backend): tambahkan HTTP bridge axum untuk IPC, memakai zesdex-middleware"
```
---
### Task 4: Add the `--http-port` CLI flag
**Files:**
- Modify: `crates/zesdex-backend/src/main.rs` (argument parsing near the `--daemon`/`--attach` flags, and the end of `run_daemon` where the bridge is spawned)
**Interfaces:** none new — wires Task 2's `bridge_tx` and Task 3's `serve_http_bridge` together, gated by the flag.
- [ ] **Step 1: Add flag parsing**
Read the existing flag-parsing code first: `grep -n -B2 -A10 "\-\-daemon\|\-\-attach" crates/zesdex-backend/src/main.rs | head -40`
Add a `--http-port <PORT>` flag using the same parsing style already present (whatever library/manual parsing the existing flags use), defaulting to `None` (HTTP transport disabled) when absent.
- [ ] **Step 2: Spawn the HTTP bridge conditionally in `run_daemon`**
After Task 2's `bridge_tx`/`bridge_rx` setup, add:
```rust
if let Some(port) = http_port {
let store_for_http = zesdex_entities::seaorm::common::store::Store::new();
let bridge_tx_for_http = bridge_tx.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime for HTTP bridge");
if let Err(e) = rt.block_on(ipc_http::serve_http_bridge(port, store_for_http, bridge_tx_for_http)) {
tracing::error!("[http-bridge] server error: {e}");
}
});
}
```
- [ ] **Step 3: Build and test**
Run: `cargo build --workspace && cargo test --workspace`
Expected: no errors, all pass.
- [ ] **Step 4: Manual smoke test — HTTP transport off by default**
Run: `cargo run -p zesdex-backend -- --daemon` (no `--http-port`). Confirm the daemon starts and the Unix-socket path works exactly as before (attach a client, verify interaction).
- [ ] **Step 5: Manual smoke test — HTTP transport enabled**
Run: `cargo run -p zesdex-backend -- --daemon --http-port 18080`. From another terminal, `curl -X POST http://127.0.0.1:18080/ipc/request -H 'Content-Type: application/json' -H 'X-Session-Id: <a-real-session-id>' -d '"Tick"'` and confirm a `200` with a JSON frame list; retry without the `X-Session-Id` header and confirm `401`.
- [ ] **Step 6: Commit**
```bash
git add crates/zesdex-backend/src/main.rs
git commit -m "feat(backend): tambahkan flag --http-port opsional untuk daemon HTTP bridge"
```
---
### Task 5: Run the full workspace verification
- [ ] **Step 1: Full build**
Run: `cargo build --workspace`
- [ ] **Step 2: Full test suite**
Run: `cargo test --workspace`
- [ ] **Step 3: Full clippy**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
- [ ] **Step 4: Confirm `zesdex-middleware` is no longer orphaned**
Run: `grep -rln "zesdex_middleware::" crates/zesdex-backend/src`
Expected: `crates/zesdex-backend/src/ipc_http.rs` (this plan's new file).
- [ ] **Step 5: Commit (if any cleanup was needed)**
```bash
git add -A
git commit -m "chore: verifikasi akhir wiring zesdex-middleware ke daemon HTTP bridge"
```
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,271 @@
# Security Quick-Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the two standalone, low-risk findings from the 2026-07-16 audit that don't require touching the OAuth/session/CMS architecture: the misleading doc-comment on the `bash` tool's credential-read behavior, and the rate limiter trusting client-controlled `X-Forwarded-For`/`X-Real-IP` headers.
**Architecture:** No structural changes. Both fixes are localized to a single file each.
**Tech Stack:** Rust, Cargo workspace (`zesdex-backend`, `zesdex-middleware`).
## Global Constraints
- No `#[allow(...)]` lint-bypass attributes may be introduced (workspace `Cargo.toml` denies `dead_code`/`unused`; CLAUDE.md forbids bypass annotations outright).
- Every new/changed `pub fn` needs an accurate doc comment (What/Flow/Why/Return per CLAUDE.md's Code Documentation section).
- Tests are inline `#[cfg(test)] mod tests` blocks in the same file, per CLAUDE.md.
- Run `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace` before each commit in this plan.
---
### Task 1: Fix misleading doc comment on `Bash::run` re: credential reads
**Context:** The audit flagged `crates/zesdex-backend/src/tool/shell_filter/credentials.rs`'s `check_credential_read` as "dead code that contradicts CLAUDE.md's claim that shell_filter blocks credential leaks." On closer reading, this is **not a behavior bug** — `crates/zesdex-backend/src/tool/shell.rs` has a deliberate, reasoned inline comment (lines 71-73) explaining that credential reads are intentionally allowed locally (the AI needs access; the real threat is committing secrets to a publLine truncated
**Files:**
- Modify: `crates/zesdex-backend/src/tool/shell.rs:47-59`
- Modify: `/mnt/code/zesdex/CLAUDE.md` (the "Shell safety" line under "Key Patterns")
- Modify: `crates/zesdex-backend/src/tool/shell_filter/credentials.rs` (module doc comment, to mark it as intentionally unused-by-`shell.rs` rather than implying it's wired in)
**Interfaces:**
- Consumes: nothing new.
- Produces: nothing new (doc-only change). No downstream task depends on this.
- [ ] **Step 1: Read the current state to confirm line numbers haven't drifted**
Run: `grep -n "check_credential_read\|Only gate destructive" crates/zesdex-backend/src/tool/shell.rs`
Expected output includes the doc comment around line 49 and the inline comment around line 71.
- [ ] **Step 2: Fix the stale doc comment on `run()`**
In `crates/zesdex-backend/src/tool/shell.rs`, replace:
```rust
/// Run a bash command (foreground or background) with safety filters and a timeout.
///
/// Flow: extract args → run `check_credential_read` then `check_git_destructive`
/// (bail if either rejects) → branch on `run_in_background`: if true, hand off
/// to the bg-bash subsystem and return the job ID; else spawn `bash -c`,
/// poll with `try_wait`, kill on timeout, format combined stdout+stderr.
///
/// Why: the safety filters run unconditionally so background jobs are also gated;
/// the timeout is enforced by polling the child rather than relying on a libc alarm
/// so cleanup stays in Rust.
///
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
/// foreground runs, or the job ID for background runs.
```
with:
```rust
/// Run a bash command (foreground or background) with a safety filter and a timeout.
///
/// Flow: extract args → run `check_git_destructive` (bail if it rejects) → branch on
/// `run_in_background`: if true, hand off to the bg-bash subsystem and return the
/// job ID; else spawn `bash -c`, poll with `try_wait`, kill on timeout, format
/// combined stdout+stderr.
///
/// Why: only destructive git operations are gated here — credential-file reads
/// (`~/.ssh/id_rsa`, `.netrc`, etc.) are deliberately NOT blocked, since the agent
/// often needs to read local config for legitimate debugging; the real leak vector
/// (committing secrets to a remote) is handled by git hooks/user review, not this
/// tool. `shell_filter::credentials::check_credential_read` exists but is
/// intentionally not called from here — see its module doc comment. The safety
/// filter runs unconditionally so background jobs are also gated; the timeout is
/// enforced by polling the child rather than relying on a libc alarm so cleanup
/// stays in Rust.
///
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
/// foreground runs, or the job ID for background runs.
```
- [ ] **Step 3: Mark `check_credential_read` as intentionally unused, not dead**
Read `crates/zesdex-backend/src/tool/shell_filter/credentials.rs` in full first:
Run: `cat crates/zesdex-backend/src/tool/shell_filter/credentials.rs`
Add a module-level doc comment at the top of the file (before any existing doc comment on `check_credential_read` itself — do not remove the existing function-level doc, just add context above it):
```rust
//! Credential-file-read detection.
//!
//! Not currently called from `tool::shell::Bash::run` — see that function's
//! doc comment for why credential reads are intentionally allowed. This
//! module is kept for callers that DO want to block credential reads (e.g.
//! a future sandboxed/untrusted-tool execution path) and is covered by its
//! own inline tests below.
```
- [ ] **Step 4: Fix CLAUDE.md's overstated claim**
In `/mnt/code/zesdex/CLAUDE.md`, find the line under "Key Patterns":
```
- **Shell safety** — `tool/shell_filter/` blocks credential leaks and destructive git commands.
```
Replace with:
```
- **Shell safety** — `tool/shell_filter/` blocks destructive git commands (`shell_filter::git::check_git_destructive`, called from `tool/shell.rs::Bash::run`). It also contains a `check_credential_read` detector for credential-file reads, but that one is intentionally NOT wired into `Bash::run` today — see the doc comment on `Bash::run` for why.
```
- [ ] **Step 5: Verify the crate still builds and lints clean**
Run: `cargo check -p zesdex-backend`
Expected: no errors (doc-only + comment changes).
Run: `cargo clippy -p zesdex-backend -- -D warnings`
Expected: no new warnings.
- [ ] **Step 6: Commit**
```bash
git add crates/zesdex-backend/src/tool/shell.rs crates/zesdex-backend/src/tool/shell_filter/credentials.rs CLAUDE.md
git commit -m "docs(shell): perbaiki doc comment shell_filter yang menyesatkan soal credential-read"
```
---
### Task 2: Stop trusting client-supplied `X-Forwarded-For`/`X-Real-IP` in the rate limiter
**Context:** `crates/zesdex-middleware/src/rate_limit.rs` derives its per-client bucket key from `X-Forwarded-For`/`X-Real-IP` headers before falling back to the real socket address. Since this middleware isn't behind a trusted reverse proxy today (confirmed: no proxy config anywhere in the workspace), any direct caller can forge these headers to get a fresh rate-limit bucket on every request. This crate is currently unused/orphaned (no axum server exists yet to mount it on — see the separateLine truncated
**Files:**
- Modify: `crates/zesdex-middleware/src/rate_limit.rs`
**Interfaces:**
- Consumes: nothing new.
- Produces: `RateLimiter`/`RateLimitLayer` public API unchanged in shape; only the client-id derivation logic changes. Any future caller (including the axum-server plan) must pass `ConnectInfo<SocketAddr>` — note this for that plan.
- [ ] **Step 1: Read the current implementation**
Run: `cat crates/zesdex-middleware/src/rate_limit.rs`
Confirm the client-id extraction logic (around lines 190-210 per the audit) checks `X-Forwarded-For` first, then `X-Real-IP`, then falls back to the connection's socket address.
- [ ] **Step 2: Write the failing test**
Add to the `#[cfg(test)] mod tests` block at the bottom of `crates/zesdex-middleware/src/rate_limit.rs` (create the block if none exists yet — confirm via the Step 1 read):
```rust
#[test]
fn client_id_ignores_spoofed_forwarded_headers_by_default() {
// A request carrying a spoofed X-Forwarded-For must NOT be treated
// as a distinct client from one with a different spoofed value —
// both should resolve to the same real socket address.
let socket_addr: std::net::SocketAddr = "127.0.0.1:9999".parse().unwrap();
let mut headers_a = axum::http::HeaderMap::new();
headers_a.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
let mut headers_b = axum::http::HeaderMap::new();
headers_b.insert("x-forwarded-for", "5.6.7.8".parse().unwrap());
let id_a = client_id(&headers_a, socket_addr, false);
let id_b = client_id(&headers_b, socket_addr, false);
assert_eq!(
id_a, id_b,
"client_id must key on the real socket address when trust_proxy_headers is false, \
not on attacker-controlled X-Forwarded-For"
);
}
#[test]
fn client_id_uses_forwarded_header_when_trust_enabled() {
// When explicitly told to trust a fronting proxy, the header value
// should be used (this is the opt-in, documented-risk path).
let socket_addr: std::net::SocketAddr = "127.0.0.1:9999".parse().unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
let id = client_id(&headers, socket_addr, true);
assert_eq!(id, "1.2.3.4");
}
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `cargo test -p zesdex-middleware client_id_ignores_spoofed -- --nocapture`
Expected: compile error (`client_id` doesn't yet take a `trust_proxy_headers: bool` parameter) or, if the function already exists without that parameter, a straightforward assertion failure since headers are currently trusted unconditionally.
- [ ] **Step 4: Add a `trust_proxy_headers` flag and make header-trust opt-in**
Locate the existing client-id derivation function (from Step 1) and change its signature to take an explicit trust flag, defaulting callers to `false`. Replace the header-first logic with:
```rust
/// Derive the rate-limit bucket key for one request.
///
/// Flow: if `trust_proxy_headers` is true, use `X-Forwarded-For` (first
/// hop) then `X-Real-IP`; otherwise always use the real connection
/// socket address, ignoring any client-supplied headers.
///
/// Why: without a trusted reverse proxy stripping/overwriting these
/// headers, they are attacker-controlled — trusting them by default lets
/// any direct caller reset their own rate-limit bucket on every request.
/// `trust_proxy_headers` must only be set to `true` when this middleware
/// sits behind a proxy that is known to overwrite (not merge) these headers.
fn client_id(
headers: &axum::http::HeaderMap,
socket_addr: std::net::SocketAddr,
trust_proxy_headers: bool,
) -> String {
if trust_proxy_headers {
if let Some(fwd) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim)
{
if !fwd.is_empty() {
return fwd.to_string();
}
}
if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
if !real_ip.is_empty() {
return real_ip.to_string();
}
}
}
socket_addr.ip().to_string()
}
```
Update every call site of the old client-id function within `rate_limit.rs` (the `Service::call`/`poll_ready` implementation that extracts headers and the socket address from the incoming `Request`) to pass `false` for `trust_proxy_headers` for now, with a `// TODO` is NOT allowed per project convention — instead add it as a named constructor parameter on `RateLimiter`/`RateLimitLayer` so callers decide explicitly:
```rust
impl RateLimiter {
/// Construct a rate limiter that keys strictly on the real connection
/// socket address (default, safe when not behind a trusted proxy).
pub fn new(/* existing params */) -> Self {
Self::with_proxy_trust(/* existing args */, false)
}
/// Construct a rate limiter that additionally trusts
/// `X-Forwarded-For`/`X-Real-IP` headers — only use this when the
/// middleware is mounted behind a reverse proxy known to overwrite
/// (not merge) these headers before they reach this service.
pub fn with_proxy_trust(/* existing params */, trust_proxy_headers: bool) -> Self {
// existing construction logic, storing trust_proxy_headers on self
}
}
```
(Exact existing constructor parameters depend on `RateLimiter`'s current fields, visible from the Step 1 read — thread `trust_proxy_headers: bool` through as an additional stored field alongside them.)
- [ ] **Step 5: Run the tests to verify they pass**
Run: `cargo test -p zesdex-middleware client_id -- --nocapture`
Expected: both new tests pass.
- [ ] **Step 6: Run the full middleware test suite and clippy**
Run: `cargo test -p zesdex-middleware && cargo clippy -p zesdex-middleware -- -D warnings`
Expected: all pass, no new warnings.
- [ ] **Step 7: Commit**
```bash
git add crates/zesdex-middleware/src/rate_limit.rs
git commit -m "fix(middleware): jangan percaya header X-Forwarded-For/X-Real-IP secara default di rate limiter"
```