Skip to main content

ai_memory/handlers/
archive.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP handlers for the archive surface (#650 follow-up per-domain
5//! split). The five archive endpoints listed below were extracted
6//! verbatim from `src/handlers/http.rs` (commit 88d9a96, lines
7//! 7196-7558). Wire compatibility is preserved via the
8//! `pub use archive::*` re-export from `src/handlers/mod.rs`; the
9//! Axum router registrations in `src/lib.rs` are unchanged.
10//!
11//! Routes wired here:
12//!
13//! * `GET    /api/v1/archive`              → [`list_archive`]
14//! * `POST   /api/v1/archive`              → [`archive_by_ids`]
15//! * `DELETE /api/v1/archive`              → [`purge_archive`]
16//! * `POST   /api/v1/archive/{id}/restore` → [`restore_archive`]
17//! * `GET    /api/v1/archive/stats`        → [`archive_stats`]
18
19use crate::models::field_names;
20use axum::{
21    Json,
22    extract::{Path, Query, State},
23    http::{HeaderMap, StatusCode},
24    response::IntoResponse,
25};
26use serde::Deserialize;
27use serde_json::json;
28
29use crate::db;
30use crate::identity::sentinels;
31use crate::validate;
32
33use super::AppState;
34use super::MAX_BULK_SIZE;
35#[cfg(feature = "sal")]
36use super::StorageBackend;
37#[cfg(feature = "sal")]
38use super::store_err_to_response;
39
40#[derive(Debug, Deserialize)]
41pub struct ArchiveListQuery {
42    pub namespace: Option<String>,
43    #[serde(default = "default_archive_limit")]
44    pub limit: Option<usize>,
45    #[serde(default)]
46    pub offset: Option<usize>,
47}
48
49#[allow(clippy::unnecessary_wraps)]
50fn default_archive_limit() -> Option<usize> {
51    Some(crate::storage::ARCHIVE_DEFAULT_PAGE_LIMIT)
52}
53
54pub async fn list_archive(
55    State(app): State<AppState>,
56    headers: axum::http::HeaderMap,
57    Query(q): Query<ArchiveListQuery>,
58) -> impl IntoResponse {
59    // #943 SECURITY-medium (Track A QC sweep, 2026-05-20) — admin-
60    // only gate. Pre-fix any caller could enumerate every archived
61    // row in the deployment. Mirror the #946 list_agents pattern;
62    // sibling of archive_stats below.
63    if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "list_archive") {
64        return resp;
65    }
66    // Ultrareview #350: validate limit range. `usize` already precludes
67    // negative values at the serde layer, but `limit=0` silently
68    // returned an empty page — indistinguishable from "no results".
69    // Require 1..=1000 and reject 0 with a specific error.
70    if matches!(q.limit, Some(0)) {
71        return (
72            StatusCode::BAD_REQUEST,
73            Json(json!({"error": "limit must be >= 1"})),
74        )
75            .into_response();
76    }
77
78    // v0.7.0 ARCH-2 FX-C2-batch5 (2026-05-27): route the postgres branch
79    // through the new `MemoryStore::list_archived` trait method (the
80    // SAL is now the canonical archive-list surface). The legacy
81    // `list_archived_via_store` downcast hatch still exists for
82    // out-of-tree callers but new routes ride the trait surface.
83    // Pre-batch5 the postgres branch dispatched through
84    // `list_archived_via_store`, which downcasted to `PostgresStore`
85    // via the `as_any_for_postgres` hatch.
86    #[cfg(feature = "sal-postgres")]
87    if matches!(app.storage_backend, StorageBackend::Postgres) {
88        let limit = q
89            .limit
90            .unwrap_or(crate::storage::ARCHIVE_DEFAULT_PAGE_LIMIT)
91            .clamp(1, crate::storage::LIST_MAX_LIMIT);
92        let offset = q.offset.unwrap_or(0);
93        return match app
94            .store
95            .list_archived(q.namespace.as_deref(), limit, offset)
96            .await
97        {
98            Ok(items) => Json(json!({"archived": items, "count": items.len()})).into_response(),
99            Err(e) => store_err_to_response(e),
100        };
101    }
102
103    let limit = q
104        .limit
105        .unwrap_or(crate::storage::ARCHIVE_DEFAULT_PAGE_LIMIT)
106        .clamp(1, crate::storage::LIST_MAX_LIMIT);
107    let offset = q.offset.unwrap_or(0);
108    // PERF-1 (FX-3): wrap rusqlite scan in `db_op`. archived_memories
109    // can carry hundreds of thousands of rows on long-running daemons;
110    // the LIMIT/OFFSET scan can take 50ms+ at the tail and would
111    // otherwise pin a tokio worker.
112    let namespace = q.namespace.clone();
113    let result = super::db_op(app.db.clone(), move |guard| {
114        db::list_archived(&guard.0, namespace.as_deref(), limit, offset)
115    })
116    .await;
117    match result {
118        Ok(items) => Json(json!({"archived": items, "count": items.len()})).into_response(),
119        Err(e) => crate::handlers::errors::handler_error_500(&e),
120    }
121}
122
123pub async fn restore_archive(
124    State(app): State<AppState>,
125    headers: HeaderMap,
126    Path(id): Path<String>,
127) -> impl IntoResponse {
128    if let Err(e) = validate::validate_id(&id) {
129        return (
130            StatusCode::BAD_REQUEST,
131            Json(json!({"error": e.to_string()})),
132        )
133            .into_response();
134    }
135
136    // #913 (security-medium / SOC2, 2026-05-19) — admin/destructive
137    // state-change audit. Restoring a row pulls it from the archived
138    // table back into the live working set; this is a privileged admin
139    // operation that must produce a forensic-chain entry BEFORE the
140    // storage write.
141    let header_agent_id = headers
142        .get(crate::HEADER_AGENT_ID)
143        .and_then(|v| v.to_str().ok());
144    let caller = crate::identity::resolve_http_agent_id(None, header_agent_id)
145        .unwrap_or_else(|_| sentinels::ANONYMOUS_INVALID.to_string());
146    crate::governance::audit::record_decision(
147        &caller,
148        "allow",
149        crate::governance::action_labels::ARCHIVE_RESTORE,
150        "",
151        json!({ "id": &id }),
152    );
153    // v0.7.0 Wave-3 Continuation 3 (Phase 19) — postgres-backed daemons
154    // route through the SAL `archive_restore` trait method. Federation
155    // fanout for restore stays sqlite-only (the `broadcast_restore_quorum`
156    // path uses sqlite-coupled fed-tracker state); postgres-backed
157    // operators relying on multi-node consistency should poll peers.
158    #[cfg(feature = "sal")]
159    if matches!(app.storage_backend, StorageBackend::Postgres) {
160        // QC P1 fix (2026-05-20): use the resolved `caller` (header)
161        // so the SAL #910 visibility filter applies — archive ops
162        // can only touch memories owned by the caller.
163        let ctx = crate::store::CallerContext::for_agent(caller.clone());
164        return match app.store.archive_restore(&ctx, &id).await {
165            Ok(true) => {
166                // #950 SECURITY-medium (Track A QC sweep, 2026-05-20)
167                // — fire subscription dispatch on the postgres restore
168                // path. Re-fetch the restored row to anchor the event
169                // on its namespace; if the lookup fails, skip dispatch
170                // rather than firing with a synthetic namespace.
171                if let Ok(mem) = app.store.get(&ctx, &id).await {
172                    let mem_owner = mem
173                        .metadata
174                        .get("agent_id")
175                        .and_then(|v| v.as_str())
176                        .map(str::to_string);
177                    super::dispatch_event_postgres(
178                        &app,
179                        "memory_restore",
180                        &id,
181                        &mem.namespace,
182                        mem_owner.as_deref(),
183                        None,
184                    )
185                    .await;
186                }
187                Json(
188                    json!({"restored": true, "id": id, (field_names::STORAGE_BACKEND): "postgres"}),
189                )
190                .into_response()
191            }
192            Ok(false) => (
193                StatusCode::NOT_FOUND,
194                Json(json!({"error": crate::errors::msg::NOT_FOUND_IN_ARCHIVE})),
195            )
196                .into_response(),
197            Err(e) => store_err_to_response(e),
198        };
199    }
200
201    // #940 (security-high, 2026-05-20) — caller-vs-row-owner gate.
202    // Pre-#940 the sqlite branch called the owner-blind
203    // `db::restore_archived(&lock.0, &id)` and any authenticated HTTP
204    // caller could restore any other owner's archived rows back into
205    // the live working set. The postgres SAL branch above was already
206    // QC-P1-fixed (2026-05-20) to pass
207    // `CallerContext::for_agent(caller)`; this routes the sqlite
208    // branch through the caller-scoped variant for parity. A
209    // non-owner attempt returns 404 (not 403) so the surface cannot
210    // be used to enumerate other owners' archived ids — mirrors the
211    // #927 `get_memory` posture.
212    // PERF-1 (FX-3): wrap the rusqlite restore in `db_op`. The restore
213    // path does INSERT...SELECT + DELETE on archived_memories, then
214    // re-inserts the row into memories triggering FTS rebuild — all
215    // on the calling thread pre-fix.
216    let id_for_restore = id.clone();
217    let caller_for_restore = caller.clone();
218    let restored = match super::db_op(app.db.clone(), move |guard| {
219        db::restore_archived_for_caller(&guard.0, &id_for_restore, &caller_for_restore)
220    })
221    .await
222    {
223        Ok(v) => v,
224        Err(e) => {
225            return crate::handlers::errors::handler_error_500(&e);
226        }
227    };
228    if !restored {
229        return (
230            StatusCode::NOT_FOUND,
231            Json(json!({"error": crate::errors::msg::NOT_FOUND_IN_ARCHIVE})),
232        )
233            .into_response();
234    }
235
236    // v0.6.2 (S29): broadcast the restore to peers so they move the row
237    // from `archived_memories` → `memories` in lockstep. Without this, a
238    // POST /api/v1/archive/{id}/restore on node-1 leaves node-2..4 with
239    // the row still archived, so node-4 never sees M1 re-enter the active
240    // set (the testbook-v3 S29 assertion). Same posture as
241    // `archive_by_ids`: on a quorum miss we short-circuit with 503 so
242    // operators can retry.
243    if let Some(fed) = app.federation.as_ref() {
244        match crate::federation::broadcast_restore_quorum(fed, &id).await {
245            Ok(tracker) => {
246                if let Err(err) = crate::federation::finalise_quorum(&tracker) {
247                    // #869 — typed 503 envelope via the shared helper.
248                    let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
249                    return super::quorum_not_met_response(&payload);
250                }
251            }
252            Err(e) => {
253                // Local commit already landed — sync-daemon catches
254                // stragglers. Same posture as `fanout_or_503`.
255                tracing::warn!("restore fanout error (local committed): {e:?}");
256            }
257        }
258    }
259
260    Json(json!({"restored": true, "id": id})).into_response()
261}
262
263#[derive(Debug, Deserialize)]
264pub struct PurgeQuery {
265    pub older_than_days: Option<i64>,
266}
267
268pub async fn purge_archive(
269    State(app): State<AppState>,
270    headers: HeaderMap,
271    Query(q): Query<PurgeQuery>,
272) -> impl IntoResponse {
273    // #911 (security-medium / SOC2, 2026-05-19) — admin action audit.
274    // `archive_purge` permanently deletes archived memories; SOC2-grade
275    // deployments must prove "who deleted what when". Forensic-chain
276    // entry is emitted BEFORE the destructive write so the audit trail
277    // captures the intent even if the storage layer errors.
278    let header_agent_id = headers
279        .get(crate::HEADER_AGENT_ID)
280        .and_then(|v| v.to_str().ok());
281    let caller = crate::identity::resolve_http_agent_id(None, header_agent_id)
282        .unwrap_or_else(|_| sentinels::ANONYMOUS_INVALID.to_string());
283
284    // #936 (security-critical, 2026-05-20) — caller-vs-row-owner gate.
285    // Pre-#936 the SAL trait method took NO caller and the handler
286    // resolved the caller only for the audit emit; the destructive
287    // DELETE then ran without any owner constraint. Any authenticated
288    // caller could destroy every owner's archive corpus.
289    //
290    // Default posture: `CallerContext::for_agent(caller)` — the SAL
291    // constrains the DELETE to rows whose `metadata.agent_id`
292    // (or the inbox-target carve-out `metadata.target_agent_id`)
293    // matches the caller. A non-admin call with no matching rows
294    // returns 200 OK with `{purged: 0}` so the surface cannot be
295    // used to enumerate other owners' archived rows.
296    //
297    // Admin/operator path: when the caller appears in the
298    // operator-configured `[admin].agent_ids` allowlist the SAL is
299    // called with `bypass_visibility = true` (the SHIP-cluster
300    // `for_admin` posture) so the DELETE runs cross-tenant — the
301    // legitimate `archive_max_days` operator wipe surface.
302    let is_admin = crate::handlers::admin_role::is_admin_caller_trusted(&app, &caller);
303    crate::governance::audit::record_decision(
304        &caller,
305        "allow",
306        crate::governance::action_labels::ARCHIVE_PURGE,
307        "",
308        json!({
309            (field_names::OLDER_THAN_DAYS): q.older_than_days,
310            (field_names::OWNER_SCOPE): if is_admin { "admin" } else { "caller" },
311        }),
312    );
313
314    // v0.7.0 Wave-3 Continuation 3 (Phase 19) — postgres-backed daemons
315    // route through the SAL trait. Wire shape preserved: `{purged}`.
316    //
317    // v0.7.0 #1062 (Agent-2 #9) — use `for_admin_checked` (not the
318    // raw `for_admin` literal) so the admin posture is a typed
319    // dependency of the construction call. A future refactor that
320    // moves the construction earlier in the function (or removes
321    // the gate) cannot accidentally hand an admin context to a
322    // non-admin caller — `is_admin` MUST be threaded through.
323    #[cfg(feature = "sal")]
324    if matches!(app.storage_backend, StorageBackend::Postgres) {
325        let ctx = crate::store::CallerContext::for_admin_checked(caller.clone(), is_admin);
326        return match app.store.archive_purge(&ctx, q.older_than_days).await {
327            Ok(n) => Json(json!({
328                "purged": n,
329                (field_names::OWNER_SCOPE): if is_admin { "admin" } else { "caller" },
330                (field_names::STORAGE_BACKEND): "postgres",
331            }))
332            .into_response(),
333            Err(e) => store_err_to_response(e),
334        };
335    }
336
337    // PERF-1 (FX-3): wrap the rusqlite DELETE in `db_op`. The purge
338    // can touch tens of thousands of archived rows; running it on the
339    // tokio worker pinned the runtime for the whole DELETE.
340    let caller_for_purge = caller.clone();
341    let older_than_days = q.older_than_days;
342    let purge_result = super::db_op(app.db.clone(), move |guard| {
343        if is_admin {
344            db::purge_archive(&guard.0, older_than_days)
345        } else {
346            db::purge_archive_for_caller(&guard.0, &caller_for_purge, older_than_days)
347        }
348    })
349    .await;
350    match purge_result {
351        Ok(n) => Json(json!({
352            "purged": n,
353            (field_names::OWNER_SCOPE): if is_admin { "admin" } else { "caller" },
354        }))
355        .into_response(),
356        Err(e) => crate::handlers::errors::handler_error_500(&e),
357    }
358}
359
360pub async fn archive_stats(
361    State(app): State<AppState>,
362    headers: axum::http::HeaderMap,
363) -> impl IntoResponse {
364    // #943 SECURITY-medium (Track A QC sweep, 2026-05-20) — admin-
365    // only gate. Pre-fix any caller could enumerate archive table
366    // size + per-reason counts + per-namespace stats. Sibling of
367    // list_archive above.
368    if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "archive_stats") {
369        return resp;
370    }
371    // v0.7.0 Wave-3 Continuation — postgres-backed daemons aggregate
372    // counts directly from the `archived_memories` table.
373    #[cfg(feature = "sal-postgres")]
374    if matches!(app.storage_backend, StorageBackend::Postgres) {
375        return match crate::store::postgres::archive_stats_via_store(&app.store).await {
376            Ok(v) => Json(v).into_response(),
377            Err(e) => store_err_to_response(e),
378        };
379    }
380
381    // PERF-1 (FX-3): wrap the rusqlite aggregate scan in `db_op`.
382    // archive_stats reads multiple GROUP BY queries off
383    // archived_memories and is admin-only — low rate but high tail
384    // latency on a saturated archive table.
385    let result = super::db_op(app.db.clone(), move |guard| db::archive_stats(&guard.0)).await;
386    match result {
387        Ok(archive_stats) => Json(archive_stats).into_response(),
388        Err(e) => crate::handlers::errors::handler_error_500(&e),
389    }
390}
391
392/// Request body for `POST /api/v1/archive` — S29 explicit archive.
393#[derive(Debug, Deserialize)]
394pub struct ArchiveByIdsBody {
395    pub ids: Vec<String>,
396    #[serde(default)]
397    pub reason: Option<String>,
398}
399
400/// POST /api/v1/archive — explicit archive of the given memory ids
401/// (S29). For each id:
402///   1. Call `db::archive_memory` locally to soft-move the row.
403///   2. If federation is configured, broadcast via
404///      `broadcast_archive_quorum` so peers land in the same terminal
405///      state (row out of `memories`, row into `archived_memories`).
406///
407/// On a quorum miss for ANY id, short-circuit with 503 via the shared
408/// `fanout_or_503`-style payload. This matches the posture of the
409/// delete + consolidate fanout endpoints.
410///
411/// Response body:
412/// ```json
413/// {"archived": [id1, id2], "missing": [id3], "count": 2}
414/// ```
415/// where `missing` enumerates ids that had no live row locally (common
416/// during retries). The response never includes content/metadata — use
417/// `GET /api/v1/archive` to list archive entries.
418#[allow(clippy::too_many_lines)]
419pub async fn archive_by_ids(
420    State(app): State<AppState>,
421    headers: HeaderMap,
422    Json(body): Json<ArchiveByIdsBody>,
423) -> impl IntoResponse {
424    // Bound the batch the same way bulk_create / sync_push do.
425    if body.ids.len() > MAX_BULK_SIZE {
426        return (
427            StatusCode::BAD_REQUEST,
428            Json(json!({"error": format!("archive limited to {} ids per request", MAX_BULK_SIZE)})),
429        )
430            .into_response();
431    }
432    // Validate all ids up-front so we never start mutating on a bad batch.
433    for id in &body.ids {
434        if let Err(e) = validate::validate_id(id) {
435            return (
436                StatusCode::BAD_REQUEST,
437                Json(json!({"error": format!("invalid id {id}: {e}")})),
438            )
439                .into_response();
440        }
441    }
442    let reason = body.reason.as_deref().unwrap_or("archive").to_string();
443
444    // #913 (security-medium / SOC2, 2026-05-19) — admin/destructive
445    // state-change audit. `archive_by_ids` performs a bulk soft-delete
446    // into the archived_memories table. Emit the forensic-chain entry
447    // BEFORE the storage writes so the audit trail captures the batch
448    // and the caller's identity regardless of partial-success behaviour.
449    let header_agent_id = headers
450        .get(crate::HEADER_AGENT_ID)
451        .and_then(|v| v.to_str().ok());
452    let caller = crate::identity::resolve_http_agent_id(None, header_agent_id)
453        .unwrap_or_else(|_| sentinels::ANONYMOUS_INVALID.to_string());
454    crate::governance::audit::record_decision(
455        &caller,
456        "allow",
457        "archive_by_ids",
458        "",
459        json!({
460            "id_count": body.ids.len(),
461            "reason": &reason,
462        }),
463    );
464    let mut archived: Vec<String> = Vec::new();
465    let mut missing: Vec<String> = Vec::new();
466
467    // v0.7.0 Wave-3 Continuation 3 (Phase 19) — postgres-backed daemons
468    // route through the SAL `archive_by_ids` trait method. The federation
469    // fanout stays sqlite-only; postgres operators relying on multi-node
470    // consistency should poll peers.
471    #[cfg(feature = "sal")]
472    if matches!(app.storage_backend, StorageBackend::Postgres) {
473        // QC P1 fix (2026-05-20): use the resolved `caller` (header)
474        // so the SAL #910 visibility filter applies — archive ops
475        // can only touch memories owned by the caller.
476        let ctx = crate::store::CallerContext::for_agent(caller.clone());
477        // Run per-id so we can split archived vs missing — the trait
478        // method bulk-archives but doesn't tell us which were missing,
479        // so we probe each via the count delta.
480        for id in &body.ids {
481            match app
482                .store
483                .archive_by_ids(&ctx, std::slice::from_ref(id), Some(&reason))
484                .await
485            {
486                Ok(1) => archived.push(id.clone()),
487                Ok(_) => missing.push(id.clone()),
488                Err(e) => return store_err_to_response(e),
489            }
490        }
491        // #950 SECURITY-medium (Track A QC sweep, 2026-05-20) — fire
492        // `memory_archive` per archived row on the postgres path. Each
493        // archive event is anchored on the (now-archived) memory id;
494        // for namespace anchoring we look the row up in archive via
495        // the SAL trait. If a follow-up `archive_get_by_id`-style read
496        // is unavailable on this trait surface, we fall back to an
497        // empty namespace + no owner (best-effort dispatch).
498        for id in &archived {
499            // The row no longer lives in `memories`; reading via
500            // app.store.get returns NotFound after the archive write.
501            // Fire the event with the caller as the agent_id and no
502            // namespace anchor — downstream subscribers match by
503            // event_type + memory_id, not namespace, for archive
504            // events.
505            super::dispatch_event_postgres(
506                &app,
507                crate::governance::OP_MEMORY_ARCHIVE,
508                id,
509                "",
510                Some(&caller),
511                None,
512            )
513            .await;
514        }
515        return (
516            StatusCode::OK,
517            Json(json!({
518                "archived": archived,
519                "missing": missing,
520                "count": archived.len(),
521                "reason": reason,
522                (field_names::STORAGE_BACKEND): "postgres",
523            })),
524        )
525            .into_response();
526    }
527
528    for id in &body.ids {
529        // Local archive. Hold the lock only across this one call per id so
530        // we can release it before a potentially slow network fanout.
531        //
532        // #940 (security-high, 2026-05-20) — caller-vs-row-owner gate.
533        // Pre-#940 the sqlite branch called the owner-blind
534        // `db::archive_memory(&lock.0, id, ...)` and any authenticated
535        // HTTP caller could bulk-archive any other owner's live rows
536        // (cross-tenant denial-of-service primitive). The postgres SAL
537        // branch above was already QC-P1-fixed (2026-05-20) to pass
538        // `CallerContext::for_agent(caller)`; this routes the sqlite
539        // branch through the caller-scoped variant for parity. A
540        // non-owner id surfaces in `missing` (same semantics as a row
541        // that wasn't live locally) so the surface cannot be used to
542        // probe other owners' live ids.
543        let moved = {
544            let lock = app.db.lock().await;
545            match db::archive_memory_for_caller(&lock.0, id, Some(&reason), &caller) {
546                Ok(v) => v,
547                Err(e) => {
548                    tracing::error!("archive_by_ids: archive_memory({id}) failed: {e}");
549                    return (
550                        StatusCode::INTERNAL_SERVER_ERROR,
551                        Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
552                    )
553                        .into_response();
554                }
555            }
556        };
557        if !moved {
558            // Row wasn't live locally — record as missing but keep going.
559            // Do NOT fan out (peers can't know to archive from a row they
560            // may have under a different state; the originator's local
561            // state is the trigger).
562            missing.push(id.clone());
563            continue;
564        }
565
566        // Fanout. Mirror the shape used by the other
567        // quorum-backed write endpoints (delete, consolidate) — on a
568        // miss, surface the `quorum_not_met` payload with 503 + Retry-After.
569        if let Some(fed) = app.federation.as_ref() {
570            match crate::federation::broadcast_archive_quorum(fed, id).await {
571                Ok(tracker) => {
572                    if let Err(err) = crate::federation::finalise_quorum(&tracker) {
573                        // #869 — typed 503 envelope via the shared helper.
574                        let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
575                        return super::quorum_not_met_response(&payload);
576                    }
577                }
578                Err(e) => {
579                    // Local commit already landed — sync-daemon catches
580                    // stragglers. Same posture as `fanout_or_503`.
581                    tracing::warn!("archive fanout error (local committed): {e:?}");
582                }
583            }
584        }
585        archived.push(id.clone());
586    }
587
588    (
589        StatusCode::OK,
590        Json(json!({
591            "archived": archived,
592            "missing": missing,
593            "count": archived.len(),
594            "reason": reason,
595        })),
596    )
597        .into_response()
598}