stemedb/crates/stemedb-api/src/handlers/rejected.rs
jordan ad07a75d0a feat: add source content to source registry, signed assertions, feed endpoint, dashboard enhancements
- Add `content: Option<String>` to SourceRecord with rkyv schema evolution
  (LegacySourceRecord compat deserializer for backward compatibility)
- Add MAX_SOURCE_CONTENT_LEN (1MB) limit with API validation
- Strip content from list responses, include in single-source GET
- Update Go SDK RegisterSourceRequest with Content field
- FCM pipeline extracts PDF text via pdftotext and passes to registration
- Dashboard impact panel fetches and displays source content with expand/collapse
- Add feed endpoint, dashboard feed panel, and signed assertion support
- Update data-structures.md, API docs, and storage docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:54:27 -07:00

90 lines
3.1 KiB
Rust

//! Admin endpoint for listing WAL records permanently rejected by the IngestWorker.
//!
//! These records passed API-level validation but were skipped during WAL replay
//! due to permanent failures (invalid signatures, corrupt serialization, etc.).
//! With the API-side signature verification fix, new rejected records should be rare.
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use stemedb_storage::{key_codec, KVStore};
use tracing::instrument;
use utoipa::ToSchema;
use crate::{dto::ErrorResponse, state::AppState};
/// Query parameters for listing rejected records.
#[derive(Debug, Deserialize)]
pub struct RejectedParams {
/// Maximum number of records to return (default: 100).
pub limit: Option<usize>,
}
/// A WAL record that was permanently skipped by the IngestWorker.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RejectedRecordDto {
/// WAL offset where the record was found.
pub offset: u64,
/// The record type (Assertion, Vote, Epoch).
pub record_type: String,
/// Why the record was rejected.
pub reason: String,
/// When the record was skipped (Unix timestamp).
pub timestamp: u64,
}
/// Response listing rejected WAL records.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RejectedRecordsResponse {
/// List of rejected records.
pub rejected: Vec<RejectedRecordDto>,
/// Total number of rejected records found.
pub count: usize,
}
/// GET /v1/admin/rejected
///
/// List WAL records that were permanently rejected by the IngestWorker.
#[utoipa::path(
get,
path = "/v1/admin/rejected",
params(
("limit" = Option<usize>, Query, description = "Maximum records to return (default: 100)")
),
responses(
(status = 200, description = "Rejected records listed", body = RejectedRecordsResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "admin"
)]
#[instrument(skip(state))]
pub async fn list_rejected(
State(state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<RejectedParams>,
) -> std::result::Result<Json<RejectedRecordsResponse>, (axum::http::StatusCode, Json<ErrorResponse>)>
{
let limit = params.limit.unwrap_or(100);
let prefix = key_codec::rejected_records_scan_prefix();
let entries = state.store.scan_prefix(&prefix).await.map_err(|e| {
tracing::error!(error = %e, "Failed to scan rejected records");
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to retrieve rejected records".to_string(),
code: "REJECTED_SCAN_ERROR".to_string(),
}),
)
})?;
let mut rejected = Vec::new();
for (_key, value) in entries.into_iter().take(limit) {
let json_str = String::from_utf8_lossy(&value);
if let Ok(dto) = serde_json::from_str::<RejectedRecordDto>(&json_str) {
rejected.push(dto);
}
}
let count = rejected.len();
Ok(Json(RejectedRecordsResponse { rejected, count }))
}